mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge main into massive-terminal-upgrade
Resolves merge conflicts: - apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger - apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions - apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling) - apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes - apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useAppStore, type AgentModel } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { useAppStore, type AgentModel } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ImageDropZone } from '@/components/ui/image-drop-zone';
|
||||
import {
|
||||
Bot,
|
||||
Send,
|
||||
User,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
Trash2,
|
||||
@@ -18,37 +16,36 @@ import {
|
||||
X,
|
||||
ImageIcon,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
||||
import { SessionManager } from "@/components/session-manager";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||
import { SessionManager } from '@/components/session-manager';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
} from '@/hooks/use-keyboard-shortcuts';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||
|
||||
export function AgentView() {
|
||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
|
||||
useAppStore();
|
||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [input, setInput] = useState("");
|
||||
const [input, setInput] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
|
||||
|
||||
// Track if initial session has been loaded
|
||||
const initialSessionLoadedRef = useRef(false);
|
||||
@@ -72,7 +69,7 @@ export function AgentView() {
|
||||
clearHistory,
|
||||
error: agentError,
|
||||
} = useElectronAgent({
|
||||
sessionId: currentSessionId || "",
|
||||
sessionId: currentSessionId || '',
|
||||
workingDirectory: currentProject?.path,
|
||||
model: selectedModel,
|
||||
onToolUse: (toolName) => {
|
||||
@@ -108,10 +105,7 @@ export function AgentView() {
|
||||
|
||||
const lastSessionId = getLastSelectedSession(currentProject.path);
|
||||
if (lastSessionId) {
|
||||
console.log(
|
||||
"[AgentView] Restoring last selected session:",
|
||||
lastSessionId
|
||||
);
|
||||
console.log('[AgentView] Restoring last selected session:', lastSessionId);
|
||||
setCurrentSessionId(lastSessionId);
|
||||
}
|
||||
}, [currentProject?.path, getLastSelectedSession]);
|
||||
@@ -127,7 +121,7 @@ export function AgentView() {
|
||||
const messageContent = input;
|
||||
const messageImages = selectedImages;
|
||||
|
||||
setInput("");
|
||||
setInput('');
|
||||
setSelectedImages([]);
|
||||
setShowImageDropZone(false);
|
||||
|
||||
@@ -147,13 +141,13 @@ export function AgentView() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, []);
|
||||
@@ -164,11 +158,11 @@ export function AgentView() {
|
||||
if (isProcessing) return;
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_FILES = 5;
|
||||
@@ -179,18 +173,14 @@ export function AgentView() {
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -216,7 +206,7 @@ export function AgentView() {
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("Image upload errors:", errors);
|
||||
console.warn('Image upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
@@ -239,7 +229,7 @@ export function AgentView() {
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
// Check if dragged items contain files
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
@@ -285,7 +275,7 @@ export function AgentView() {
|
||||
if (items && items.length > 0) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === "file") {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
@@ -309,9 +299,9 @@ export function AgentView() {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === "file") {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
e.preventDefault(); // Prevent default paste of file path
|
||||
files.push(file);
|
||||
}
|
||||
@@ -329,14 +319,14 @@ export function AgentView() {
|
||||
);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearChat = async () => {
|
||||
if (!confirm("Are you sure you want to clear this conversation?")) return;
|
||||
if (!confirm('Are you sure you want to clear this conversation?')) return;
|
||||
await clearHistory();
|
||||
};
|
||||
|
||||
@@ -347,14 +337,13 @@ export function AgentView() {
|
||||
|
||||
const threshold = 50; // 50px threshold for "near bottom"
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
||||
threshold;
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
|
||||
|
||||
setIsUserAtBottom(isAtBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom function
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -375,7 +364,7 @@ export function AgentView() {
|
||||
if (isUserAtBottom && messages.length > 0) {
|
||||
// Use a small delay to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
scrollToBottom("smooth");
|
||||
scrollToBottom('smooth');
|
||||
}, 100);
|
||||
}
|
||||
}, [messages, isUserAtBottom, scrollToBottom]);
|
||||
@@ -385,7 +374,7 @@ export function AgentView() {
|
||||
if (currentSessionId && messages.length > 0) {
|
||||
// Scroll immediately without animation when switching sessions
|
||||
setTimeout(() => {
|
||||
scrollToBottom("auto");
|
||||
scrollToBottom('auto');
|
||||
setIsUserAtBottom(true);
|
||||
}, 100);
|
||||
}
|
||||
@@ -414,7 +403,7 @@ export function AgentView() {
|
||||
quickCreateSessionRef.current();
|
||||
}
|
||||
},
|
||||
description: "Create new session",
|
||||
description: 'Create new session',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,9 +423,7 @@ export function AgentView() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -450,8 +437,8 @@ export function AgentView() {
|
||||
messages.length === 0
|
||||
? [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant" as const,
|
||||
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(),
|
||||
@@ -460,10 +447,7 @@ export function AgentView() {
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex overflow-hidden bg-background"
|
||||
data-testid="agent-view"
|
||||
>
|
||||
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
||||
{/* Session Manager Sidebar */}
|
||||
{showSessionManager && currentProject && (
|
||||
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
|
||||
@@ -498,12 +482,10 @@ export function AgentView() {
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
AI Agent
|
||||
</h1>
|
||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.name}
|
||||
{currentSessionId && !isConnected && " - Connecting..."}
|
||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,7 +503,10 @@ export function AgentView() {
|
||||
data-testid="model-selector"
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
|
||||
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
|
||||
'Claude ',
|
||||
''
|
||||
) || 'Sonnet'}
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -530,17 +515,12 @@ export function AgentView() {
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selectedModel === model.id && "bg-accent"
|
||||
)}
|
||||
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
|
||||
data-testid={`model-option-${model.id}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{model.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{model.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -554,9 +534,7 @@ export function AgentView() {
|
||||
</div>
|
||||
)}
|
||||
{agentError && (
|
||||
<span className="text-xs text-destructive font-medium">
|
||||
{agentError}
|
||||
</span>
|
||||
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
||||
)}
|
||||
{currentSessionId && messages.length > 0 && (
|
||||
<Button
|
||||
@@ -583,9 +561,7 @@ export function AgentView() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -595,7 +571,7 @@ export function AgentView() {
|
||||
className="gap-2"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? "View" : "Show"} Sessions
|
||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -610,20 +586,20 @@ export function AgentView() {
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-4 max-w-4xl",
|
||||
message.role === "user" ? "flex-row-reverse ml-auto" : ""
|
||||
'flex gap-4 max-w-4xl',
|
||||
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "bg-primary/10 ring-1 ring-primary/20"
|
||||
: "bg-muted ring-1 ring-border"
|
||||
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
|
||||
message.role === 'assistant'
|
||||
? 'bg-primary/10 ring-1 ring-primary/20'
|
||||
: 'bg-muted ring-1 ring-border'
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
{message.role === 'assistant' ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -633,76 +609,67 @@ export function AgentView() {
|
||||
{/* Message Bubble */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card border border-border"
|
||||
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border'
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
{message.role === 'assistant' ? (
|
||||
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||
{message.content}
|
||||
</p>
|
||||
<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>
|
||||
{message.role === 'user' && message.images && message.images.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{message.images.length} image
|
||||
{message.images.length > 1 ? 's' : ''} attached
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.images.map((image, index) => {
|
||||
// Construct proper data URL from base64 data and mime type
|
||||
const dataUrl = image.data.startsWith('data:')
|
||||
? image.data
|
||||
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
|
||||
return (
|
||||
<div
|
||||
key={image.id || `img-${index}`}
|
||||
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={image.filename || `Attached image ${index + 1}`}
|
||||
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
|
||||
{image.filename || `Image ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] mt-2 font-medium",
|
||||
message.role === "user"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
'text-[11px] mt-2 font-medium',
|
||||
message.role === 'user'
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -720,20 +687,18 @@ export function AgentView() {
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Thinking...
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -761,7 +726,7 @@ export function AgentView() {
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""} attached
|
||||
{selectedImages.length > 1 ? 's' : ''} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSelectedImages([])}
|
||||
@@ -815,8 +780,8 @@ export function AgentView() {
|
||||
{/* 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"
|
||||
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
||||
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -827,9 +792,7 @@ export function AgentView() {
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver
|
||||
? "Drop your images here..."
|
||||
: "Describe what you want to build..."
|
||||
isDragOver ? 'Drop your images here...' : 'Describe what you want to build...'
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -838,16 +801,16 @@ export function AgentView() {
|
||||
disabled={isProcessing || !isConnected}
|
||||
data-testid="agent-input"
|
||||
className={cn(
|
||||
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
|
||||
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
|
||||
selectedImages.length > 0 && "border-primary/30",
|
||||
isDragOver && "border-primary bg-primary/5"
|
||||
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
selectedImages.length > 0 && 'border-primary/30',
|
||||
isDragOver && 'border-primary bg-primary/5'
|
||||
)}
|
||||
/>
|
||||
{selectedImages.length > 0 && !isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""}
|
||||
{selectedImages.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
@@ -865,10 +828,9 @@ export function AgentView() {
|
||||
onClick={toggleImageDropZone}
|
||||
disabled={isProcessing || !isConnected}
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-xl border-border",
|
||||
showImageDropZone &&
|
||||
"bg-primary/10 text-primary border-primary/30",
|
||||
selectedImages.length > 0 && "border-primary/30 text-primary"
|
||||
'h-11 w-11 rounded-xl border-border',
|
||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||
selectedImages.length > 0 && 'border-primary/30 text-primary'
|
||||
)}
|
||||
title="Attach images"
|
||||
>
|
||||
@@ -879,9 +841,7 @@ export function AgentView() {
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() && selectedImages.length === 0) ||
|
||||
isProcessing ||
|
||||
!isConnected
|
||||
(!input.trim() && selectedImages.length === 0) || isProcessing || !isConnected
|
||||
}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
data-testid="send-message"
|
||||
@@ -892,11 +852,9 @@ export function AgentView() {
|
||||
|
||||
{/* 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
|
||||
Press{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||
send
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -907,9 +865,9 @@ export function AgentView() {
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
useAppStore,
|
||||
FileTreeNode,
|
||||
ProjectAnalysis,
|
||||
Feature,
|
||||
} from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
@@ -30,29 +18,29 @@ import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
"node_modules",
|
||||
".git",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
".cache",
|
||||
"coverage",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".venv",
|
||||
"venv",
|
||||
".env",
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'.DS_Store',
|
||||
'*.log',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.venv',
|
||||
'venv',
|
||||
'.env',
|
||||
];
|
||||
|
||||
const shouldIgnore = (name: string) => {
|
||||
return IGNORE_PATTERNS.some((pattern) => {
|
||||
if (pattern.startsWith("*")) {
|
||||
if (pattern.startsWith('*')) {
|
||||
return name.endsWith(pattern.slice(1));
|
||||
}
|
||||
return name === pattern;
|
||||
@@ -60,8 +48,8 @@ const shouldIgnore = (name: string) => {
|
||||
};
|
||||
|
||||
const getExtension = (filename: string): string => {
|
||||
const parts = filename.split(".");
|
||||
return parts.length > 1 ? parts.pop() || "" : "";
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts.pop() || '' : '';
|
||||
};
|
||||
|
||||
export function AnalysisView() {
|
||||
@@ -74,9 +62,7 @@ export function AnalysisView() {
|
||||
clearAnalysis,
|
||||
} = useAppStore();
|
||||
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
|
||||
const [specGenerated, setSpecGenerated] = useState(false);
|
||||
const [specError, setSpecError] = useState<string | null>(null);
|
||||
@@ -123,7 +109,7 @@ export function AnalysisView() {
|
||||
|
||||
return nodes;
|
||||
} catch (error) {
|
||||
console.error("Failed to scan directory:", path, error);
|
||||
console.error('Failed to scan directory:', path, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -148,7 +134,7 @@ export function AnalysisView() {
|
||||
if (item.extension) {
|
||||
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
|
||||
} else {
|
||||
byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
|
||||
byExt['(no extension)'] = (byExt['(no extension)'] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,17 +165,11 @@ export function AnalysisView() {
|
||||
|
||||
setProjectAnalysis(analysis);
|
||||
} catch (error) {
|
||||
console.error("Analysis failed:", error);
|
||||
console.error('Analysis failed:', error);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [
|
||||
currentProject,
|
||||
setIsAnalyzing,
|
||||
clearAnalysis,
|
||||
scanDirectory,
|
||||
setProjectAnalysis,
|
||||
]);
|
||||
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
|
||||
|
||||
// Generate app_spec.txt from analysis
|
||||
const generateSpec = useCallback(async () => {
|
||||
@@ -204,7 +184,7 @@ export function AnalysisView() {
|
||||
|
||||
// Read key files to understand the project better
|
||||
const fileContents: Record<string, string> = {};
|
||||
const keyFiles = ["package.json", "README.md", "tsconfig.json"];
|
||||
const keyFiles = ['package.json', 'README.md', 'tsconfig.json'];
|
||||
|
||||
// Collect file paths from analysis
|
||||
const collectFilePaths = (
|
||||
@@ -217,15 +197,13 @@ export function AnalysisView() {
|
||||
if (!node.isDirectory) {
|
||||
paths.push(node.path);
|
||||
} else if (node.children && currentDepth < maxDepth) {
|
||||
paths.push(
|
||||
...collectFilePaths(node.children, maxDepth, currentDepth + 1)
|
||||
);
|
||||
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
|
||||
collectFilePaths(projectAnalysis.fileTree);
|
||||
|
||||
// Try to read key configuration files
|
||||
for (const keyFile of keyFiles) {
|
||||
@@ -245,40 +223,34 @@ export function AnalysisView() {
|
||||
const extensions = projectAnalysis.filesByExtension;
|
||||
|
||||
// Check package.json for dependencies
|
||||
if (fileContents["package.json"]) {
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents["package.json"]);
|
||||
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"])
|
||||
stack.push("React");
|
||||
if (pkg.dependencies?.next) stack.push("Next.js");
|
||||
if (pkg.dependencies?.vue) stack.push("Vue");
|
||||
if (pkg.dependencies?.angular) stack.push("Angular");
|
||||
if (pkg.dependencies?.express) stack.push("Express");
|
||||
if (pkg.dependencies?.electron) stack.push("Electron");
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React');
|
||||
if (pkg.dependencies?.next) stack.push('Next.js');
|
||||
if (pkg.dependencies?.vue) stack.push('Vue');
|
||||
if (pkg.dependencies?.angular) stack.push('Angular');
|
||||
if (pkg.dependencies?.express) stack.push('Express');
|
||||
if (pkg.dependencies?.electron) stack.push('Electron');
|
||||
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
|
||||
stack.push("TypeScript");
|
||||
if (
|
||||
pkg.devDependencies?.tailwindcss ||
|
||||
pkg.dependencies?.tailwindcss
|
||||
)
|
||||
stack.push("Tailwind CSS");
|
||||
stack.push('TypeScript');
|
||||
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss)
|
||||
stack.push('Tailwind CSS');
|
||||
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
|
||||
stack.push("Playwright");
|
||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
|
||||
stack.push("Jest");
|
||||
stack.push('Playwright');
|
||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest');
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Detect by file extensions
|
||||
if (extensions["ts"] || extensions["tsx"]) stack.push("TypeScript");
|
||||
if (extensions["py"]) stack.push("Python");
|
||||
if (extensions["go"]) stack.push("Go");
|
||||
if (extensions["rs"]) stack.push("Rust");
|
||||
if (extensions["java"]) stack.push("Java");
|
||||
if (extensions["css"] || extensions["scss"] || extensions["sass"])
|
||||
stack.push("CSS/SCSS");
|
||||
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript');
|
||||
if (extensions['py']) stack.push('Python');
|
||||
if (extensions['go']) stack.push('Go');
|
||||
if (extensions['rs']) stack.push('Rust');
|
||||
if (extensions['java']) stack.push('Java');
|
||||
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS');
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(stack)];
|
||||
@@ -286,9 +258,9 @@ export function AnalysisView() {
|
||||
|
||||
// Get project name from package.json or folder name
|
||||
const getProjectName = () => {
|
||||
if (fileContents["package.json"]) {
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents["package.json"]);
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
if (pkg.name) return pkg.name;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
@@ -300,30 +272,30 @@ export function AnalysisView() {
|
||||
|
||||
// Get project description from package.json or README
|
||||
const getProjectDescription = () => {
|
||||
if (fileContents["package.json"]) {
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents["package.json"]);
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
if (pkg.description) return pkg.description;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
if (fileContents["README.md"]) {
|
||||
if (fileContents['README.md']) {
|
||||
// Extract first paragraph from README
|
||||
const lines = fileContents["README.md"].split("\n");
|
||||
const lines = fileContents['README.md'].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed &&
|
||||
!trimmed.startsWith("#") &&
|
||||
!trimmed.startsWith("!") &&
|
||||
!trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('!') &&
|
||||
trimmed.length > 20
|
||||
) {
|
||||
return trimmed.substring(0, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "A software project";
|
||||
return 'A software project';
|
||||
};
|
||||
|
||||
// Group files by directory for structure analysis
|
||||
@@ -336,7 +308,7 @@ export function AnalysisView() {
|
||||
for (const dir of topLevelDirs) {
|
||||
structure.push(` <directory name="${dir}" />`);
|
||||
}
|
||||
return structure.join("\n");
|
||||
return structure.join('\n');
|
||||
};
|
||||
|
||||
const projectName = getProjectName();
|
||||
@@ -356,20 +328,15 @@ export function AnalysisView() {
|
||||
<languages>
|
||||
${Object.entries(projectAnalysis.filesByExtension)
|
||||
.filter(([ext]: [string, number]) =>
|
||||
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
|
||||
ext
|
||||
)
|
||||
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext)
|
||||
)
|
||||
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
([ext, count]: [string, number]) =>
|
||||
` <language ext=".${ext}" count="${count}" />`
|
||||
)
|
||||
.join("\n")}
|
||||
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
|
||||
.join('\n')}
|
||||
</languages>
|
||||
<frameworks>
|
||||
${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
|
||||
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')}
|
||||
</frameworks>
|
||||
</technology_stack>
|
||||
|
||||
@@ -387,11 +354,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
([ext, count]: [string, number]) =>
|
||||
` <extension type="${
|
||||
ext.startsWith("(") ? ext : "." + ext
|
||||
}" count="${count}" />`
|
||||
` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`
|
||||
)
|
||||
.join("\n")}
|
||||
.join('\n')}
|
||||
</file_breakdown>
|
||||
|
||||
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
|
||||
@@ -405,13 +370,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
if (writeResult.success) {
|
||||
setSpecGenerated(true);
|
||||
} else {
|
||||
setSpecError(writeResult.error || "Failed to write spec file");
|
||||
setSpecError(writeResult.error || 'Failed to write spec file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate spec:", error);
|
||||
setSpecError(
|
||||
error instanceof Error ? error.message : "Failed to generate spec"
|
||||
);
|
||||
console.error('Failed to generate spec:', error);
|
||||
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
|
||||
} finally {
|
||||
setIsGeneratingSpec(false);
|
||||
}
|
||||
@@ -430,7 +393,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
// Read key files to understand the project
|
||||
const fileContents: Record<string, string> = {};
|
||||
const keyFiles = ["package.json", "README.md"];
|
||||
const keyFiles = ['package.json', 'README.md'];
|
||||
|
||||
// Try to read key configuration files
|
||||
for (const keyFile of keyFiles) {
|
||||
@@ -481,21 +444,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
// Check for test directories and files
|
||||
const hasTests =
|
||||
topLevelDirs.includes("tests") ||
|
||||
topLevelDirs.includes("test") ||
|
||||
topLevelDirs.includes("__tests__") ||
|
||||
allFilePaths.some(
|
||||
(p) => p.includes(".spec.") || p.includes(".test.")
|
||||
);
|
||||
topLevelDirs.includes('tests') ||
|
||||
topLevelDirs.includes('test') ||
|
||||
topLevelDirs.includes('__tests__') ||
|
||||
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.'));
|
||||
|
||||
if (hasTests) {
|
||||
detectedFeatures.push({
|
||||
category: "Testing",
|
||||
description: "Automated test suite",
|
||||
category: 'Testing',
|
||||
description: 'Automated test suite',
|
||||
steps: [
|
||||
"Step 1: Tests directory exists",
|
||||
"Step 2: Test files are present",
|
||||
"Step 3: Run test suite",
|
||||
'Step 1: Tests directory exists',
|
||||
'Step 2: Test files are present',
|
||||
'Step 3: Run test suite',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -503,50 +464,50 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
// Check for components directory (UI components)
|
||||
const hasComponents =
|
||||
topLevelDirs.includes("components") ||
|
||||
allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
|
||||
topLevelDirs.includes('components') ||
|
||||
allFilePaths.some((p) => p.toLowerCase().includes('/components/'));
|
||||
|
||||
if (hasComponents) {
|
||||
detectedFeatures.push({
|
||||
category: "UI/Design",
|
||||
description: "Component-based UI architecture",
|
||||
category: 'UI/Design',
|
||||
description: 'Component-based UI architecture',
|
||||
steps: [
|
||||
"Step 1: Components directory exists",
|
||||
"Step 2: UI components are defined",
|
||||
"Step 3: Components are reusable",
|
||||
'Step 1: Components directory exists',
|
||||
'Step 2: UI components are defined',
|
||||
'Step 3: Components are reusable',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for src directory (organized source code)
|
||||
if (topLevelDirs.includes("src")) {
|
||||
if (topLevelDirs.includes('src')) {
|
||||
detectedFeatures.push({
|
||||
category: "Project Structure",
|
||||
description: "Organized source code structure",
|
||||
category: 'Project Structure',
|
||||
description: 'Organized source code structure',
|
||||
steps: [
|
||||
"Step 1: Source directory exists",
|
||||
"Step 2: Code is properly organized",
|
||||
"Step 3: Follows best practices",
|
||||
'Step 1: Source directory exists',
|
||||
'Step 2: Code is properly organized',
|
||||
'Step 3: Follows best practices',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check package.json for dependencies and detect features
|
||||
if (fileContents["package.json"]) {
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents["package.json"]);
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
|
||||
// React/Next.js app detection
|
||||
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"]) {
|
||||
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
|
||||
detectedFeatures.push({
|
||||
category: "Frontend",
|
||||
description: "React-based user interface",
|
||||
category: 'Frontend',
|
||||
description: 'React-based user interface',
|
||||
steps: [
|
||||
"Step 1: React is installed",
|
||||
"Step 2: Components render correctly",
|
||||
"Step 3: State management works",
|
||||
'Step 1: React is installed',
|
||||
'Step 2: Components render correctly',
|
||||
'Step 3: State management works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -554,12 +515,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
if (pkg.dependencies?.next) {
|
||||
detectedFeatures.push({
|
||||
category: "Framework",
|
||||
description: "Next.js framework integration",
|
||||
category: 'Framework',
|
||||
description: 'Next.js framework integration',
|
||||
steps: [
|
||||
"Step 1: Next.js is configured",
|
||||
"Step 2: Pages/routes are defined",
|
||||
"Step 3: Server-side rendering works",
|
||||
'Step 1: Next.js is configured',
|
||||
'Step 2: Pages/routes are defined',
|
||||
'Step 3: Server-side rendering works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -569,33 +530,30 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
if (
|
||||
pkg.devDependencies?.typescript ||
|
||||
pkg.dependencies?.typescript ||
|
||||
extensions["ts"] ||
|
||||
extensions["tsx"]
|
||||
extensions['ts'] ||
|
||||
extensions['tsx']
|
||||
) {
|
||||
detectedFeatures.push({
|
||||
category: "Developer Experience",
|
||||
description: "TypeScript type safety",
|
||||
category: 'Developer Experience',
|
||||
description: 'TypeScript type safety',
|
||||
steps: [
|
||||
"Step 1: TypeScript is configured",
|
||||
"Step 2: Type definitions exist",
|
||||
"Step 3: Code compiles without errors",
|
||||
'Step 1: TypeScript is configured',
|
||||
'Step 2: Type definitions exist',
|
||||
'Step 3: Code compiles without errors',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Tailwind CSS
|
||||
if (
|
||||
pkg.devDependencies?.tailwindcss ||
|
||||
pkg.dependencies?.tailwindcss
|
||||
) {
|
||||
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) {
|
||||
detectedFeatures.push({
|
||||
category: "UI/Design",
|
||||
description: "Tailwind CSS styling",
|
||||
category: 'UI/Design',
|
||||
description: 'Tailwind CSS styling',
|
||||
steps: [
|
||||
"Step 1: Tailwind is configured",
|
||||
"Step 2: Styles are applied",
|
||||
"Step 3: Responsive design works",
|
||||
'Step 1: Tailwind is configured',
|
||||
'Step 2: Styles are applied',
|
||||
'Step 3: Responsive design works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -604,12 +562,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
// ESLint/Prettier (code quality)
|
||||
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
|
||||
detectedFeatures.push({
|
||||
category: "Developer Experience",
|
||||
description: "Code quality tools",
|
||||
category: 'Developer Experience',
|
||||
description: 'Code quality tools',
|
||||
steps: [
|
||||
"Step 1: Linter is configured",
|
||||
"Step 2: Code passes lint checks",
|
||||
"Step 3: Formatting is consistent",
|
||||
'Step 1: Linter is configured',
|
||||
'Step 2: Code passes lint checks',
|
||||
'Step 3: Formatting is consistent',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -618,29 +576,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
// Electron (desktop app)
|
||||
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
|
||||
detectedFeatures.push({
|
||||
category: "Platform",
|
||||
description: "Electron desktop application",
|
||||
category: 'Platform',
|
||||
description: 'Electron desktop application',
|
||||
steps: [
|
||||
"Step 1: Electron is configured",
|
||||
"Step 2: Main process runs",
|
||||
"Step 3: Renderer process loads",
|
||||
'Step 1: Electron is configured',
|
||||
'Step 2: Main process runs',
|
||||
'Step 3: Renderer process loads',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Playwright testing
|
||||
if (
|
||||
pkg.devDependencies?.playwright ||
|
||||
pkg.devDependencies?.["@playwright/test"]
|
||||
) {
|
||||
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) {
|
||||
detectedFeatures.push({
|
||||
category: "Testing",
|
||||
description: "Playwright end-to-end testing",
|
||||
category: 'Testing',
|
||||
description: 'Playwright end-to-end testing',
|
||||
steps: [
|
||||
"Step 1: Playwright is configured",
|
||||
"Step 2: E2E tests are defined",
|
||||
"Step 3: Tests pass successfully",
|
||||
'Step 1: Playwright is configured',
|
||||
'Step 2: E2E tests are defined',
|
||||
'Step 3: Tests pass successfully',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -651,17 +606,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
}
|
||||
|
||||
// Check for documentation
|
||||
if (
|
||||
topLevelFiles.includes("readme.md") ||
|
||||
topLevelDirs.includes("docs")
|
||||
) {
|
||||
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
|
||||
detectedFeatures.push({
|
||||
category: "Documentation",
|
||||
description: "Project documentation",
|
||||
category: 'Documentation',
|
||||
description: 'Project documentation',
|
||||
steps: [
|
||||
"Step 1: README exists",
|
||||
"Step 2: Documentation is comprehensive",
|
||||
"Step 3: Setup instructions are clear",
|
||||
'Step 1: README exists',
|
||||
'Step 2: Documentation is comprehensive',
|
||||
'Step 3: Setup instructions are clear',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -669,18 +621,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
// Check for CI/CD configuration
|
||||
const hasCICD =
|
||||
topLevelDirs.includes(".github") ||
|
||||
topLevelFiles.includes(".gitlab-ci.yml") ||
|
||||
topLevelFiles.includes(".travis.yml");
|
||||
topLevelDirs.includes('.github') ||
|
||||
topLevelFiles.includes('.gitlab-ci.yml') ||
|
||||
topLevelFiles.includes('.travis.yml');
|
||||
|
||||
if (hasCICD) {
|
||||
detectedFeatures.push({
|
||||
category: "DevOps",
|
||||
description: "CI/CD pipeline configuration",
|
||||
category: 'DevOps',
|
||||
description: 'CI/CD pipeline configuration',
|
||||
steps: [
|
||||
"Step 1: CI config exists",
|
||||
"Step 2: Pipeline runs on push",
|
||||
"Step 3: Automated checks pass",
|
||||
'Step 1: CI config exists',
|
||||
'Step 2: Pipeline runs on push',
|
||||
'Step 3: Automated checks pass',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -688,20 +640,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
// Check for API routes (Next.js API or Express)
|
||||
const hasAPIRoutes = allFilePaths.some(
|
||||
(p) =>
|
||||
p.includes("/api/") ||
|
||||
p.includes("/routes/") ||
|
||||
p.includes("/endpoints/")
|
||||
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/')
|
||||
);
|
||||
|
||||
if (hasAPIRoutes) {
|
||||
detectedFeatures.push({
|
||||
category: "Backend",
|
||||
description: "API endpoints",
|
||||
category: 'Backend',
|
||||
description: 'API endpoints',
|
||||
steps: [
|
||||
"Step 1: API routes are defined",
|
||||
"Step 2: Endpoints respond correctly",
|
||||
"Step 3: Error handling is implemented",
|
||||
'Step 1: API routes are defined',
|
||||
'Step 2: Endpoints respond correctly',
|
||||
'Step 3: Error handling is implemented',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -710,37 +659,34 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
// Check for state management
|
||||
const hasStateManagement = allFilePaths.some(
|
||||
(p) =>
|
||||
p.includes("/store/") ||
|
||||
p.includes("/stores/") ||
|
||||
p.includes("/redux/") ||
|
||||
p.includes("/context/")
|
||||
p.includes('/store/') ||
|
||||
p.includes('/stores/') ||
|
||||
p.includes('/redux/') ||
|
||||
p.includes('/context/')
|
||||
);
|
||||
|
||||
if (hasStateManagement) {
|
||||
detectedFeatures.push({
|
||||
category: "Architecture",
|
||||
description: "State management system",
|
||||
category: 'Architecture',
|
||||
description: 'State management system',
|
||||
steps: [
|
||||
"Step 1: Store is configured",
|
||||
"Step 2: State updates correctly",
|
||||
"Step 3: Components access state",
|
||||
'Step 1: Store is configured',
|
||||
'Step 2: State updates correctly',
|
||||
'Step 3: Components access state',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for configuration files
|
||||
if (
|
||||
topLevelFiles.includes("tsconfig.json") ||
|
||||
topLevelFiles.includes("package.json")
|
||||
) {
|
||||
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) {
|
||||
detectedFeatures.push({
|
||||
category: "Configuration",
|
||||
description: "Project configuration files",
|
||||
category: 'Configuration',
|
||||
description: 'Project configuration files',
|
||||
steps: [
|
||||
"Step 1: Config files exist",
|
||||
"Step 2: Configuration is valid",
|
||||
"Step 3: Build process works",
|
||||
'Step 1: Config files exist',
|
||||
'Step 2: Configuration is valid',
|
||||
'Step 3: Build process works',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -752,12 +698,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
// If no features were detected, add a default feature
|
||||
if (detectedFeatures.length === 0) {
|
||||
detectedFeatures.push({
|
||||
category: "Core",
|
||||
description: "Basic project structure",
|
||||
category: 'Core',
|
||||
description: 'Basic project structure',
|
||||
steps: [
|
||||
"Step 1: Project directory exists",
|
||||
"Step 2: Files are present",
|
||||
"Step 3: Project can be loaded",
|
||||
'Step 1: Project directory exists',
|
||||
'Step 2: Files are present',
|
||||
'Step 3: Project can be loaded',
|
||||
],
|
||||
passes: true,
|
||||
});
|
||||
@@ -765,7 +711,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
|
||||
// Create each feature using the features API
|
||||
if (!api.features) {
|
||||
throw new Error("Features API not available");
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
|
||||
for (const detectedFeature of detectedFeatures) {
|
||||
@@ -774,17 +720,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
category: detectedFeature.category,
|
||||
description: detectedFeature.description,
|
||||
steps: detectedFeature.steps,
|
||||
status: "backlog",
|
||||
status: 'backlog',
|
||||
});
|
||||
}
|
||||
|
||||
setFeatureListGenerated(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate feature list:", error);
|
||||
console.error('Failed to generate feature list:', error);
|
||||
setFeatureListError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate feature list"
|
||||
error instanceof Error ? error.message : 'Failed to generate feature list'
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingFeatureList(false);
|
||||
@@ -810,7 +754,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm"
|
||||
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => {
|
||||
@@ -840,17 +784,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.extension && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
.{node.extension}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
|
||||
)}
|
||||
</div>
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child: FileTreeNode) =>
|
||||
renderNode(child, depth + 1)
|
||||
)}
|
||||
</div>
|
||||
<div>{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -868,26 +806,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="analysis-view"
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="analysis-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Project Analysis</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={runAnalysis}
|
||||
disabled={isAnalyzing}
|
||||
data-testid="analyze-project-button"
|
||||
>
|
||||
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
@@ -909,13 +838,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Click "Analyze Project" to scan your codebase and get
|
||||
insights about its structure.
|
||||
Click "Analyze Project" to scan your codebase and get insights about its
|
||||
structure.
|
||||
</p>
|
||||
<Button
|
||||
onClick={runAnalysis}
|
||||
data-testid="analyze-project-button-empty"
|
||||
>
|
||||
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Start Analysis
|
||||
</Button>
|
||||
@@ -936,27 +862,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
Statistics
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Analyzed{" "}
|
||||
{new Date(projectAnalysis.analyzedAt).toLocaleString()}
|
||||
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total Files
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Total Files</span>
|
||||
<span className="font-medium" data-testid="total-files">
|
||||
{projectAnalysis.totalFiles}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total Directories
|
||||
</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
data-testid="total-directories"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">Total Directories</span>
|
||||
<span className="font-medium" data-testid="total-directories">
|
||||
{projectAnalysis.totalDirectories}
|
||||
</span>
|
||||
</div>
|
||||
@@ -973,15 +891,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(projectAnalysis.filesByExtension)
|
||||
.sort(
|
||||
(a: [string, number], b: [string, number]) =>
|
||||
b[1] - a[1]
|
||||
)
|
||||
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||
.slice(0, 15)
|
||||
.map(([ext, count]: [string, number]) => (
|
||||
<div key={ext} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{ext.startsWith("(") ? ext : `.${ext}`}
|
||||
{ext.startsWith('(') ? ext : `.${ext}`}
|
||||
</span>
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
@@ -997,14 +912,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<FileText className="w-4 h-4" />
|
||||
Generate Specification
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create app_spec.txt from analysis
|
||||
</CardDescription>
|
||||
<CardDescription>Create app_spec.txt from analysis</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate a project specification file based on the analyzed
|
||||
codebase structure and detected technologies.
|
||||
Generate a project specification file based on the analyzed codebase structure
|
||||
and detected technologies.
|
||||
</p>
|
||||
<Button
|
||||
onClick={generateSpec}
|
||||
@@ -1052,15 +965,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Generate Feature List
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create features from analysis
|
||||
</CardDescription>
|
||||
<CardDescription>Create features from analysis</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically detect and generate a feature list based on
|
||||
the analyzed codebase structure, dependencies, and project
|
||||
configuration.
|
||||
Automatically detect and generate a feature list based on the analyzed codebase
|
||||
structure, dependencies, and project configuration.
|
||||
</p>
|
||||
<Button
|
||||
onClick={generateFeatureList}
|
||||
@@ -1110,18 +1020,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
File Tree
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{projectAnalysis.totalFiles} files in{" "}
|
||||
{projectAnalysis.totalDirectories} directories
|
||||
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '}
|
||||
directories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className="p-0 overflow-y-auto h-full"
|
||||
data-testid="analysis-file-tree"
|
||||
>
|
||||
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
|
||||
<div className="p-2">
|
||||
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
|
||||
renderNode(node)
|
||||
)}
|
||||
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
rectIntersection,
|
||||
pointerWithin,
|
||||
} from "@dnd-kit/core";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useWindowState } from "@/hooks/use-window-state";
|
||||
} from '@dnd-kit/core';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
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 { 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 {
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
@@ -31,15 +31,15 @@ import {
|
||||
FeatureSuggestionsDialog,
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
} from "./board-view/dialogs";
|
||||
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
|
||||
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
|
||||
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
|
||||
import { WorktreePanel } from "./board-view/worktree-panel";
|
||||
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
|
||||
import { COLUMNS } from "./board-view/constants";
|
||||
} from './board-view/dialogs';
|
||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
||||
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||
import { WorktreePanel } from './board-view/worktree-panel';
|
||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
||||
import { COLUMNS } from './board-view/constants';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
useBoardDragDrop,
|
||||
@@ -51,12 +51,10 @@ import {
|
||||
useBoardPersistence,
|
||||
useFollowUpState,
|
||||
useSuggestionsState,
|
||||
} from "./board-view/hooks";
|
||||
} from './board-view/hooks';
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<
|
||||
ReturnType<typeof useAppStore.getState>["getWorktrees"]
|
||||
> = [];
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
|
||||
/** Delay before starting a newly created feature to allow state to settle */
|
||||
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
|
||||
@@ -98,26 +96,18 @@ export function BoardView() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [showOutputModal, setShowOutputModal] = useState(false);
|
||||
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
|
||||
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
|
||||
useState(false);
|
||||
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
|
||||
useState(false);
|
||||
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(new Set());
|
||||
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = useState(false);
|
||||
const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false);
|
||||
const [showCompletedModal, setShowCompletedModal] = useState(false);
|
||||
const [deleteCompletedFeature, setDeleteCompletedFeature] =
|
||||
useState<Feature | null>(null);
|
||||
const [deleteCompletedFeature, setDeleteCompletedFeature] = useState<Feature | null>(null);
|
||||
// State for viewing plan in read-only mode
|
||||
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
|
||||
|
||||
// Worktree dialog states
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||
@@ -158,7 +148,7 @@ export function BoardView() {
|
||||
closeSuggestionsDialog,
|
||||
} = useSuggestionsState();
|
||||
// Search filter for Kanban cards
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Plan approval loading state
|
||||
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
|
||||
// Derive spec creation state from store - check if current project is the one being created
|
||||
@@ -175,14 +165,11 @@ export function BoardView() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.contextExists(
|
||||
currentProject.path,
|
||||
featureId
|
||||
);
|
||||
const result = await api.autoMode.contextExists(currentProject.path, featureId);
|
||||
|
||||
return result.success && result.exists === true;
|
||||
} catch (error) {
|
||||
console.error("[Board] Error checking context:", error);
|
||||
console.error('[Board] Error checking context:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -228,9 +215,7 @@ export function BoardView() {
|
||||
|
||||
// Get unique categories from existing features AND persisted categories for autocomplete suggestions
|
||||
const categorySuggestions = useMemo(() => {
|
||||
const featureCategories = hookFeatures
|
||||
.map((f) => f.category)
|
||||
.filter(Boolean);
|
||||
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
|
||||
// Merge feature categories with persisted categories
|
||||
const allCategories = [...featureCategories, ...persistedCategories];
|
||||
return [...new Set(allCategories)].sort();
|
||||
@@ -264,7 +249,7 @@ export function BoardView() {
|
||||
setBranchSuggestions(localBranches);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BoardView] Error fetching branches:", error);
|
||||
console.error('[BoardView] Error fetching branches:', error);
|
||||
setBranchSuggestions([]);
|
||||
}
|
||||
};
|
||||
@@ -276,8 +261,8 @@ export function BoardView() {
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce(
|
||||
(counts, feature) => {
|
||||
if (feature.status !== "completed") {
|
||||
const branch = feature.branchName ?? "main";
|
||||
if (feature.status !== 'completed') {
|
||||
const branch = feature.branchName ?? 'main';
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -304,8 +289,9 @@ export function BoardView() {
|
||||
}, []);
|
||||
|
||||
// Use persistence hook
|
||||
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
|
||||
useBoardPersistence({ currentProject });
|
||||
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
|
||||
currentProject,
|
||||
});
|
||||
|
||||
// Memoize the removed worktrees handler to prevent infinite loops
|
||||
const handleRemovedWorktrees = useCallback(
|
||||
@@ -332,15 +318,13 @@ export function BoardView() {
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
return hookFeatures.filter((f) => {
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
return isRunning || f.status === "in_progress";
|
||||
return isRunning || f.status === 'in_progress';
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Get current worktree info (path) for filtering features
|
||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||
const currentWorktreeInfo = currentProject
|
||||
? getCurrentWorktree(currentProject.path)
|
||||
: null;
|
||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
@@ -359,9 +343,7 @@ export function BoardView() {
|
||||
return worktrees.find((w) => w.isMain);
|
||||
} else {
|
||||
// Specific worktree selected - find it by path
|
||||
return worktrees.find(
|
||||
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
|
||||
);
|
||||
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||
}
|
||||
}, [worktrees, currentWorktreePath]);
|
||||
|
||||
@@ -371,7 +353,7 @@ export function BoardView() {
|
||||
// Get the branch for the currently selected worktree (for defaulting new features)
|
||||
// Use the branch from selectedWorktree, or fall back to main worktree's branch
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
|
||||
// Extract all action handlers into a hook
|
||||
const {
|
||||
@@ -418,6 +400,26 @@ export function BoardView() {
|
||||
outputFeature,
|
||||
projectPath: currentProject?.path || null,
|
||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||
onWorktreeAutoSelect: (newWorktree) => {
|
||||
if (!currentProject) return;
|
||||
// Check if worktree already exists in the store (by branch name)
|
||||
const currentWorktrees = getWorktrees(currentProject.path);
|
||||
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
|
||||
|
||||
// Only add if it doesn't already exist (to avoid duplicates)
|
||||
if (!existingWorktree) {
|
||||
const newWorktreeInfo = {
|
||||
path: newWorktree.path,
|
||||
branch: newWorktree.branch,
|
||||
isMain: false,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
};
|
||||
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
|
||||
}
|
||||
// Select the worktree (whether it existed or was just added)
|
||||
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
|
||||
},
|
||||
currentWorktreeBranch,
|
||||
});
|
||||
|
||||
@@ -431,17 +433,17 @@ export function BoardView() {
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
category: "PR Review",
|
||||
category: 'PR Review',
|
||||
description,
|
||||
steps: [],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: "opus" as const,
|
||||
thinkingLevel: "none" as const,
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: worktree.branch,
|
||||
priority: 1, // High priority for PR feedback
|
||||
planningMode: "skip" as const,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
@@ -454,7 +456,7 @@ export function BoardView() {
|
||||
const newFeature = latestFeatures.find(
|
||||
(f) =>
|
||||
f.branchName === worktree.branch &&
|
||||
f.status === "backlog" &&
|
||||
f.status === 'backlog' &&
|
||||
f.description.includes(`PR #${prNumber}`)
|
||||
);
|
||||
|
||||
@@ -473,17 +475,17 @@ export function BoardView() {
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
category: "Maintenance",
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
steps: [],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: "opus" as const,
|
||||
thinkingLevel: "none" as const,
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: worktree.branch,
|
||||
priority: 1, // High priority for conflict resolution
|
||||
planningMode: "skip" as const,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
@@ -495,8 +497,8 @@ export function BoardView() {
|
||||
const newFeature = latestFeatures.find(
|
||||
(f) =>
|
||||
f.branchName === worktree.branch &&
|
||||
f.status === "backlog" &&
|
||||
f.description.includes("Pull latest from origin/main")
|
||||
f.status === 'backlog' &&
|
||||
f.description.includes('Pull latest from origin/main')
|
||||
);
|
||||
|
||||
if (newFeature) {
|
||||
@@ -544,22 +546,21 @@ export function BoardView() {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Only process events for the current project
|
||||
const eventProjectPath =
|
||||
"projectPath" in event ? event.projectPath : undefined;
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
|
||||
if (eventProjectPath && eventProjectPath !== currentProject.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_feature_start":
|
||||
case 'auto_mode_feature_start':
|
||||
// Feature is now confirmed running - remove from pending
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_feature_complete":
|
||||
case "auto_mode_error":
|
||||
case 'auto_mode_feature_complete':
|
||||
case 'auto_mode_error':
|
||||
// Feature completed or errored - remove from pending if still there
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
@@ -600,8 +601,7 @@ export function BoardView() {
|
||||
|
||||
// Count currently running tasks + pending features
|
||||
// Use ref to get the latest running tasks without causing effect re-runs
|
||||
const currentRunning =
|
||||
runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
|
||||
// No available slots, skip check
|
||||
@@ -614,7 +614,7 @@ export function BoardView() {
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeatures = currentFeatures.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
@@ -671,9 +671,8 @@ export function BoardView() {
|
||||
// If feature has no branchName and primary worktree is selected, assign primary branch
|
||||
if (currentWorktreePath === null && !feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path
|
||||
? getPrimaryWorktreeBranch(currentProject.path)
|
||||
: null) || "main";
|
||||
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
|
||||
'main';
|
||||
await persistFeatureUpdate(feature.id, {
|
||||
branchName: primaryBranch,
|
||||
});
|
||||
@@ -699,7 +698,7 @@ export function BoardView() {
|
||||
|
||||
// Check immediately, then every 3 seconds
|
||||
checkAndStartFeatures();
|
||||
const interval = setInterval(checkAndStartFeatures, 1000);
|
||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
||||
|
||||
return () => {
|
||||
// Mark as inactive to prevent any pending async operations from continuing
|
||||
@@ -759,9 +758,7 @@ export function BoardView() {
|
||||
// Find feature for pending plan approval
|
||||
const pendingApprovalFeature = useMemo(() => {
|
||||
if (!pendingPlanApproval) return null;
|
||||
return (
|
||||
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
|
||||
);
|
||||
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
|
||||
}, [pendingPlanApproval, hookFeatures]);
|
||||
|
||||
// Handle plan approval
|
||||
@@ -774,7 +771,7 @@ export function BoardView() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.approvePlan) {
|
||||
throw new Error("Plan approval API not available");
|
||||
throw new Error('Plan approval API not available');
|
||||
}
|
||||
|
||||
const result = await api.autoMode.approvePlan(
|
||||
@@ -790,7 +787,7 @@ export function BoardView() {
|
||||
const currentFeature = hookFeatures.find((f) => f.id === featureId);
|
||||
updateFeature(featureId, {
|
||||
planSpec: {
|
||||
status: "approved",
|
||||
status: 'approved',
|
||||
content: editedPlan || pendingPlanApproval.planContent,
|
||||
version: currentFeature?.planSpec?.version || 1,
|
||||
approvedAt: new Date().toISOString(),
|
||||
@@ -800,10 +797,10 @@ export function BoardView() {
|
||||
// Reload features from server to ensure sync
|
||||
loadFeatures();
|
||||
} else {
|
||||
console.error("[Board] Failed to approve plan:", result.error);
|
||||
console.error('[Board] Failed to approve plan:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error approving plan:", error);
|
||||
console.error('[Board] Error approving plan:', error);
|
||||
} finally {
|
||||
setIsPlanApprovalLoading(false);
|
||||
setPendingPlanApproval(null);
|
||||
@@ -829,7 +826,7 @@ export function BoardView() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.approvePlan) {
|
||||
throw new Error("Plan approval API not available");
|
||||
throw new Error('Plan approval API not available');
|
||||
}
|
||||
|
||||
const result = await api.autoMode.approvePlan(
|
||||
@@ -845,9 +842,9 @@ export function BoardView() {
|
||||
// Get current feature to preserve version
|
||||
const currentFeature = hookFeatures.find((f) => f.id === featureId);
|
||||
updateFeature(featureId, {
|
||||
status: "backlog",
|
||||
status: 'backlog',
|
||||
planSpec: {
|
||||
status: "rejected",
|
||||
status: 'rejected',
|
||||
content: pendingPlanApproval.planContent,
|
||||
version: currentFeature?.planSpec?.version || 1,
|
||||
reviewedByUser: true,
|
||||
@@ -856,10 +853,10 @@ export function BoardView() {
|
||||
// Reload features from server to ensure sync
|
||||
loadFeatures();
|
||||
} else {
|
||||
console.error("[Board] Failed to reject plan:", result.error);
|
||||
console.error('[Board] Failed to reject plan:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error rejecting plan:", error);
|
||||
console.error('[Board] Error rejecting plan:', error);
|
||||
} finally {
|
||||
setIsPlanApprovalLoading(false);
|
||||
setPendingPlanApproval(null);
|
||||
@@ -882,8 +879,8 @@ export function BoardView() {
|
||||
|
||||
// Determine the planning mode for approval (skip should never have a plan requiring approval)
|
||||
const mode = feature.planningMode;
|
||||
const approvalMode: "lite" | "spec" | "full" =
|
||||
mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
|
||||
const approvalMode: 'lite' | 'spec' | 'full' =
|
||||
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
|
||||
|
||||
// Re-open the approval dialog with the feature's plan data
|
||||
setPendingPlanApproval({
|
||||
@@ -898,10 +895,7 @@ export function BoardView() {
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="board-view-no-project"
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
@@ -909,10 +903,7 @@ export function BoardView() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="board-view-loading"
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
@@ -941,7 +932,7 @@ export function BoardView() {
|
||||
addFeatureShortcut={{
|
||||
key: shortcuts.addFeature,
|
||||
action: () => setShowAddDialog(true),
|
||||
description: "Add new feature",
|
||||
description: 'Add new feature',
|
||||
}}
|
||||
isMounted={isMounted}
|
||||
/>
|
||||
@@ -1096,8 +1087,8 @@ export function BoardView() {
|
||||
<AgentOutputModal
|
||||
open={showOutputModal}
|
||||
onClose={() => setShowOutputModal(false)}
|
||||
featureDescription={outputFeature?.description || ""}
|
||||
featureId={outputFeature?.id || ""}
|
||||
featureDescription={outputFeature?.description || ''}
|
||||
featureId={outputFeature?.id || ''}
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
/>
|
||||
@@ -1106,7 +1097,7 @@ export function BoardView() {
|
||||
<ArchiveAllVerifiedDialog
|
||||
open={showArchiveAllVerifiedDialog}
|
||||
onOpenChange={setShowArchiveAllVerifiedDialog}
|
||||
verifiedCount={getColumnFeatures("verified").length}
|
||||
verifiedCount={getColumnFeatures('verified').length}
|
||||
onConfirm={async () => {
|
||||
await handleArchiveAllVerified();
|
||||
setShowArchiveAllVerifiedDialog(false);
|
||||
@@ -1148,7 +1139,7 @@ export function BoardView() {
|
||||
}
|
||||
}}
|
||||
feature={pendingApprovalFeature}
|
||||
planContent={pendingPlanApproval?.planContent || ""}
|
||||
planContent={pendingPlanApproval?.planContent || ''}
|
||||
onApprove={handlePlanApprove}
|
||||
onReject={handlePlanReject}
|
||||
isLoading={isPlanApprovalLoading}
|
||||
@@ -1183,17 +1174,10 @@ export function BoardView() {
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
};
|
||||
setWorktrees(currentProject.path, [
|
||||
...currentWorktrees,
|
||||
newWorktreeInfo,
|
||||
]);
|
||||
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
|
||||
|
||||
// Now set the current worktree with both path and branch
|
||||
setCurrentWorktree(
|
||||
currentProject.path,
|
||||
newWorktree.path,
|
||||
newWorktree.branch
|
||||
);
|
||||
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
|
||||
|
||||
// Trigger refresh to get full worktree details (hasChanges, etc.)
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
@@ -1208,9 +1192,7 @@ export function BoardView() {
|
||||
worktree={selectedWorktreeForAction}
|
||||
affectedFeatureCount={
|
||||
selectedWorktreeForAction
|
||||
? hookFeatures.filter(
|
||||
(f) => f.branchName === selectedWorktreeForAction.branch
|
||||
).length
|
||||
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
||||
: 0
|
||||
}
|
||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||
@@ -1262,9 +1244,7 @@ export function BoardView() {
|
||||
|
||||
// Persist changes asynchronously and in parallel
|
||||
Promise.all(
|
||||
featuresToUpdate.map((feature) =>
|
||||
persistFeatureUpdate(feature.id, { prUrl })
|
||||
)
|
||||
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
|
||||
).catch(console.error);
|
||||
}
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Bot } from "lucide-react";
|
||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, Bot } from 'lucide-react';
|
||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
@@ -30,6 +30,14 @@ export function BoardHeader({
|
||||
addFeatureShortcut,
|
||||
isMounted,
|
||||
}: BoardHeaderProps) {
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div>
|
||||
@@ -37,6 +45,9 @@ export function BoardHeader({
|
||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - only show for CLI users (not API key users) */}
|
||||
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
|
||||
|
||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div
|
||||
@@ -66,10 +77,7 @@ export function BoardHeader({
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Auto Mode
|
||||
</Label>
|
||||
<Switch
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { KanbanCard } from "./kanban-card";
|
||||
export { KanbanColumn } from "./kanban-column";
|
||||
export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
AgentTaskInfo,
|
||||
parseAgentContext,
|
||||
formatModelName,
|
||||
DEFAULT_MODEL,
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Cpu,
|
||||
Brain,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
Expand,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { SummaryDialog } from './summary-dialog';
|
||||
|
||||
/**
|
||||
* Formats thinking level for compact display
|
||||
*/
|
||||
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||
if (!level || level === 'none') return '';
|
||||
const labels: Record<ThinkingLevel, string> = {
|
||||
none: '',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra',
|
||||
};
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
interface AgentInfoPanelProps {
|
||||
feature: Feature;
|
||||
contextContent?: string;
|
||||
summary?: string;
|
||||
isCurrentAutoTask?: boolean;
|
||||
}
|
||||
|
||||
export function AgentInfoPanel({
|
||||
feature,
|
||||
contextContent,
|
||||
summary,
|
||||
isCurrentAutoTask,
|
||||
}: AgentInfoPanelProps) {
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
|
||||
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
|
||||
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
if (contextContent) {
|
||||
const info = parseAgentContext(contextContent);
|
||||
setAgentInfo(info);
|
||||
return;
|
||||
}
|
||||
|
||||
if (feature.status === 'backlog') {
|
||||
setAgentInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const currentProject = (window as any).__currentProject;
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const info = parseAgentContext(result.content);
|
||||
setAgentInfo(info);
|
||||
}
|
||||
} else {
|
||||
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
|
||||
const result = await api.readFile(contextPath);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const info = parseAgentContext(result.content);
|
||||
setAgentInfo(info);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.debug('[KanbanCard] No context file for feature:', feature.id);
|
||||
}
|
||||
};
|
||||
|
||||
loadContext();
|
||||
|
||||
if (isCurrentAutoTask) {
|
||||
const interval = setInterval(loadContext, 3000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
||||
// Model/Preset Info for Backlog Cards
|
||||
if (showAgentInfo && feature.status === 'backlog') {
|
||||
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" />
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
{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">
|
||||
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent Info Panel for non-backlog cards
|
||||
if (showAgentInfo && 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" />
|
||||
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||
</div>
|
||||
{agentInfo.currentPhase && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
||||
agentInfo.currentPhase === 'planning' &&
|
||||
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
|
||||
agentInfo.currentPhase === 'action' &&
|
||||
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
|
||||
agentInfo.currentPhase === 'verification' &&
|
||||
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
|
||||
)}
|
||||
>
|
||||
{agentInfo.currentPhase}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task List Progress */}
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<ListTodo className="w-3 h-3" />
|
||||
<span>
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
||||
{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'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary for waiting_approval and verified */}
|
||||
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo.summary) && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||
<Sparkles className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate font-medium">Summary</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
|
||||
title="View full summary"
|
||||
data-testid={`expand-summary-${feature.id}`}
|
||||
>
|
||||
<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">
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo.toolCallCount} tool calls
|
||||
</span>
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Always render SummaryDialog if showAgentInfo is true (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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Edit,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
StopCircle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Eye,
|
||||
Wand2,
|
||||
Archive,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CardActionsProps {
|
||||
feature: Feature;
|
||||
isCurrentAutoTask: boolean;
|
||||
hasContext?: boolean;
|
||||
shortcutKey?: string;
|
||||
onEdit: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
onResume?: () => void;
|
||||
onForceStop?: () => void;
|
||||
onManualVerify?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
}
|
||||
|
||||
export function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
hasContext,
|
||||
shortcutKey,
|
||||
onEdit,
|
||||
onViewOutput,
|
||||
onVerify,
|
||||
onResume,
|
||||
onForceStop,
|
||||
onManualVerify,
|
||||
onFollowUp,
|
||||
onImplement,
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
}: CardActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
|
||||
{isCurrentAutoTask && (
|
||||
<>
|
||||
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprovePlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`approve-plan-running-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Approve Plan</span>
|
||||
</Button>
|
||||
)}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
{shortcutKey && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||
data-testid={`shortcut-key-${feature.id}`}
|
||||
>
|
||||
{shortcutKey}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{onForceStop && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForceStop();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`force-stop-${feature.id}`}
|
||||
>
|
||||
<StopCircle className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
||||
<>
|
||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprovePlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`approve-plan-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Approve Plan
|
||||
</Button>
|
||||
)}
|
||||
{feature.skipTests && onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`manual-verify-${feature.id}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : hasContext && onResume ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResume();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`resume-feature-${feature.id}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : onVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-feature-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
{onViewOutput && !feature.skipTests && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-inprogress-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||
<>
|
||||
{/* Logs button */}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-verified-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Complete button */}
|
||||
{onComplete && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onComplete();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`complete-${feature.id}`}
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Complete</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
|
||||
<>
|
||||
{/* Refine prompt button */}
|
||||
{onFollowUp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFollowUp();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`follow-up-${feature.id}`}
|
||||
>
|
||||
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Refine</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
|
||||
{feature.prUrl && onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-${feature.id}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`mark-as-verified-${feature.id}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Mark as Verified
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`edit-backlog-${feature.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
{feature.planSpec?.content && onViewPlan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewPlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-plan-${feature.id}`}
|
||||
title="View Plan"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onImplement && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImplement();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`make-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Make
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardBadgesProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PriorityBadgesProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
const isJustFinished = useMemo(() => {
|
||||
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
|
||||
return false;
|
||||
}
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000;
|
||||
return currentTime - finishedTime < twoMinutes;
|
||||
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature.justFinishedAt || feature.status !== 'waiting_approval') {
|
||||
return;
|
||||
}
|
||||
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000;
|
||||
const timeRemaining = twoMinutes - (currentTime - finishedTime);
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
|
||||
const showPriorityBadges =
|
||||
feature.priority ||
|
||||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
|
||||
isJustFinished;
|
||||
|
||||
if (!showPriorityBadges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
{/* Priority badge */}
|
||||
{feature.priority && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardBadge
|
||||
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
|
||||
feature.priority === 1 &&
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||
feature.priority === 2 &&
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
|
||||
feature.priority === 3 &&
|
||||
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
|
||||
)}
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>
|
||||
{feature.priority === 1
|
||||
? 'High Priority'
|
||||
: feature.priority === 2
|
||||
? 'Medium Priority'
|
||||
: 'Low Priority'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Manual verification badge */}
|
||||
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardBadge
|
||||
className="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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Manual verification required</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
|
||||
|
||||
interface CardContentSectionsProps {
|
||||
feature: Feature;
|
||||
useWorktrees: boolean;
|
||||
showSteps: boolean;
|
||||
}
|
||||
|
||||
export function CardContentSections({
|
||||
feature,
|
||||
useWorktrees,
|
||||
showSteps,
|
||||
}: CardContentSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Target Branch Display */}
|
||||
{useWorktrees && feature.branchName && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono truncate" title={feature.branchName}>
|
||||
{feature.branchName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR URL Display */}
|
||||
{typeof feature.prUrl === 'string' &&
|
||||
/^https?:\/\//i.test(feature.prUrl) &&
|
||||
(() => {
|
||||
const prNumber = feature.prUrl.split('/').pop();
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href={feature.prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors"
|
||||
title={feature.prUrl}
|
||||
data-testid={`pr-url-${feature.id}`}
|
||||
>
|
||||
<GitPullRequest className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">
|
||||
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
|
||||
</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Steps Preview */}
|
||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{feature.steps.slice(0, 3).map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
|
||||
>
|
||||
{feature.status === 'verified' ? (
|
||||
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.steps.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-5">
|
||||
+{feature.steps.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useState } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
GripVertical,
|
||||
Edit,
|
||||
Loader2,
|
||||
Trash2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cpu,
|
||||
} 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';
|
||||
|
||||
interface CardHeaderProps {
|
||||
feature: Feature;
|
||||
isDraggable: boolean;
|
||||
isCurrentAutoTask: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
}
|
||||
|
||||
export function CardHeaderSection({
|
||||
feature,
|
||||
isDraggable,
|
||||
isCurrentAutoTask,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<CardHeader className="p-3 pb-2 block">
|
||||
{/* Running task header */}
|
||||
{isCurrentAutoTask && (
|
||||
<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" />
|
||||
{feature.startedAt && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
className="text-[var(--status-in-progress)] text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-running-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-running-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backlog header */}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-backlog-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting approval / Verified header */}
|
||||
{!isCurrentAutoTask &&
|
||||
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`edit-${
|
||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||
}-${feature.id}`}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`logs-${
|
||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||
}-${feature.id}`}
|
||||
title="Logs"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-${
|
||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||
}-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* In progress header */}
|
||||
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{onViewOutput && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-logs-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-2" />
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="flex items-start gap-2">
|
||||
{isDraggable && (
|
||||
<div
|
||||
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
|
||||
data-testid={`drag-handle-${feature.id}`}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{feature.titleGenerating ? (
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||
</div>
|
||||
) : feature.title ? (
|
||||
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
|
||||
{feature.title}
|
||||
</CardTitle>
|
||||
) : null}
|
||||
<CardDescription
|
||||
className={cn(
|
||||
'text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground',
|
||||
!isDescriptionExpanded && 'line-clamp-3'
|
||||
)}
|
||||
>
|
||||
{feature.description || feature.summary || feature.id}
|
||||
</CardDescription>
|
||||
{(feature.description || feature.summary || '').length > 100 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescriptionExpanded(!isDescriptionExpanded);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1.5 transition-colors"
|
||||
data-testid={`toggle-description-${feature.id}`}
|
||||
>
|
||||
{isDescriptionExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
<span>Less</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<span>More</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Feature"
|
||||
description="Are you sure you want to delete this feature? This action cannot be undone."
|
||||
testId="delete-confirmation-dialog"
|
||||
confirmTestId="confirm-delete-button"
|
||||
/>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { AgentInfoPanel } from './agent-info-panel';
|
||||
export { CardActions } from './card-actions';
|
||||
export { CardBadges, PriorityBadges } from './card-badges';
|
||||
export { CardContentSections } from './card-content-sections';
|
||||
export { CardHeaderSection } from './card-header';
|
||||
export { KanbanCard } from './kanban-card';
|
||||
export { SummaryDialog } from './summary-dialog';
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { CardBadges, PriorityBadges } from './card-badges';
|
||||
import { CardHeaderSection } from './card-header';
|
||||
import { CardContentSections } from './card-content-sections';
|
||||
import { AgentInfoPanel } from './agent-info-panel';
|
||||
import { CardActions } from './card-actions';
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
onResume?: () => void;
|
||||
onForceStop?: () => void;
|
||||
onManualVerify?: () => void;
|
||||
onMoveBackToInProgress?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
contextContent?: string;
|
||||
summary?: string;
|
||||
opacity?: number;
|
||||
glassmorphism?: boolean;
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
feature,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onVerify,
|
||||
onResume,
|
||||
onForceStop,
|
||||
onManualVerify,
|
||||
onMoveBackToInProgress: _onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onImplement,
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
contextContent,
|
||||
summary,
|
||||
opacity = 100,
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
}: KanbanCardProps) {
|
||||
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
|
||||
|
||||
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
|
||||
|
||||
const isDraggable =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
const borderStyle: React.CSSProperties = { ...style };
|
||||
if (!cardBorderEnabled) {
|
||||
(borderStyle as Record<string, string>).borderWidth = '0px';
|
||||
(borderStyle as Record<string, string>).borderColor = 'transparent';
|
||||
} else if (cardBorderOpacity !== 100) {
|
||||
(borderStyle as Record<string, string>).borderWidth = '1px';
|
||||
(borderStyle as Record<string, string>).borderColor =
|
||||
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
||||
}
|
||||
|
||||
const cardElement = (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={isCurrentAutoTask ? style : borderStyle}
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Premium shadow system
|
||||
'shadow-sm hover:shadow-md hover:shadow-black/10',
|
||||
// Subtle lift on hover
|
||||
'hover:-translate-y-0.5',
|
||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
|
||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
|
||||
!isDragging && 'bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||
// Error state - using CSS variable
|
||||
feature.error &&
|
||||
!isCurrentAutoTask &&
|
||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
!isDraggable && 'cursor-default'
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
onDoubleClick={onEdit}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{!isDragging && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl bg-card -z-10',
|
||||
glassmorphism && 'backdrop-blur-sm'
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status Badges Row */}
|
||||
<CardBadges feature={feature} />
|
||||
|
||||
{/* Category row */}
|
||||
<div className="px-3 pt-4">
|
||||
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
|
||||
</div>
|
||||
|
||||
{/* Priority and Manual Verification badges */}
|
||||
<PriorityBadges feature={feature} />
|
||||
|
||||
{/* Card Header */}
|
||||
<CardHeaderSection
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
/>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
{/* Content Sections */}
|
||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
|
||||
|
||||
{/* Agent Info Panel */}
|
||||
<AgentInfoPanel
|
||||
feature={feature}
|
||||
contextContent={contextContent}
|
||||
summary={summary}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
onEdit={onEdit}
|
||||
onViewOutput={onViewOutput}
|
||||
onVerify={onVerify}
|
||||
onResume={onResume}
|
||||
onForceStop={onForceStop}
|
||||
onManualVerify={onManualVerify}
|
||||
onFollowUp={onFollowUp}
|
||||
onImplement={onImplement}
|
||||
onComplete={onComplete}
|
||||
onViewPlan={onViewPlan}
|
||||
onApprovePlan={onApprovePlan}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Wrap with animated border when in progress
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { AgentTaskInfo } from '@/lib/agent-context-parser';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
interface SummaryDialogProps {
|
||||
feature: Feature;
|
||||
agentInfo: AgentTaskInfo | null;
|
||||
summary?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SummaryDialog({
|
||||
feature,
|
||||
agentInfo,
|
||||
summary,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: SummaryDialogProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
data-testid={`summary-dialog-${feature.id}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
|
||||
Implementation Summary
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
className="text-sm"
|
||||
title={feature.description || feature.summary || ''}
|
||||
>
|
||||
{(() => {
|
||||
const displayText = feature.description || feature.summary || 'No description';
|
||||
return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText;
|
||||
})()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
|
||||
<Markdown>
|
||||
{feature.summary || summary || agentInfo?.summary || 'No summary available'}
|
||||
</Markdown>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="close-summary-button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { LogViewer } from "@/components/ui/log-viewer";
|
||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
||||
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
open: boolean;
|
||||
@@ -26,7 +25,7 @@ interface AgentOutputModalProps {
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = "parsed" | "raw" | "changes";
|
||||
type ViewMode = 'parsed' | 'raw' | 'changes';
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
@@ -36,13 +35,13 @@ export function AgentOutputModal({
|
||||
featureStatus,
|
||||
onNumberKeyPress,
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
|
||||
const [projectPath, setProjectPath] = useState<string>("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
|
||||
const [projectPath, setProjectPath] = useState<string>('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>("");
|
||||
const projectPathRef = useRef<string>('');
|
||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
@@ -75,22 +74,19 @@ export function AgentOutputModal({
|
||||
|
||||
// Use features API to get agent output
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(
|
||||
currentProject.path,
|
||||
featureId
|
||||
);
|
||||
const result = await api.features.getAgentOutput(currentProject.path, featureId);
|
||||
|
||||
if (result.success) {
|
||||
setOutput(result.content || "");
|
||||
setOutput(result.content || '');
|
||||
} else {
|
||||
setOutput("");
|
||||
setOutput('');
|
||||
}
|
||||
} else {
|
||||
setOutput("");
|
||||
setOutput('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load output:", error);
|
||||
setOutput("");
|
||||
console.error('Failed to load output:', error);
|
||||
setOutput('');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -108,38 +104,32 @@ export function AgentOutputModal({
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Filter events for this specific feature only (skip events without featureId)
|
||||
if ("featureId" in event && event.featureId !== featureId) {
|
||||
if ('featureId' in event && event.featureId !== featureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newContent = "";
|
||||
let newContent = '';
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_progress":
|
||||
newContent = event.content || "";
|
||||
case 'auto_mode_progress':
|
||||
newContent = event.content || '';
|
||||
break;
|
||||
case "auto_mode_tool":
|
||||
const toolName = event.tool || "Unknown Tool";
|
||||
const toolInput = event.input
|
||||
? JSON.stringify(event.input, null, 2)
|
||||
: "";
|
||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
||||
toolInput ? `Input: ${toolInput}\n` : ""
|
||||
}`;
|
||||
case 'auto_mode_tool': {
|
||||
const toolName = event.tool || 'Unknown Tool';
|
||||
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
|
||||
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
|
||||
break;
|
||||
case "auto_mode_phase":
|
||||
}
|
||||
case 'auto_mode_phase': {
|
||||
const phaseEmoji =
|
||||
event.phase === "planning"
|
||||
? "📋"
|
||||
: event.phase === "action"
|
||||
? "⚡"
|
||||
: "✅";
|
||||
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
|
||||
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
||||
break;
|
||||
case "auto_mode_error":
|
||||
}
|
||||
case 'auto_mode_error':
|
||||
newContent = `\n❌ Error: ${event.error}\n`;
|
||||
break;
|
||||
case "auto_mode_ultrathink_preparation":
|
||||
case 'auto_mode_ultrathink_preparation': {
|
||||
// Format thinking level preparation information
|
||||
let prepContent = `\n🧠 Ultrathink Preparation\n`;
|
||||
|
||||
@@ -169,66 +159,74 @@ export function AgentOutputModal({
|
||||
|
||||
newContent = prepContent;
|
||||
break;
|
||||
case "planning_started":
|
||||
}
|
||||
case 'planning_started': {
|
||||
// Show when planning mode begins
|
||||
if ("mode" in event && "message" in event) {
|
||||
if ('mode' in event && 'message' in event) {
|
||||
const modeLabel =
|
||||
event.mode === "lite"
|
||||
? "Lite"
|
||||
: event.mode === "spec"
|
||||
? "Spec"
|
||||
: "Full";
|
||||
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
|
||||
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_approval_required":
|
||||
}
|
||||
case 'plan_approval_required':
|
||||
// Show when plan requires approval
|
||||
if ("planningMode" in event) {
|
||||
if ('planningMode' in event) {
|
||||
newContent = `\n⏸️ Plan generated - waiting for your approval...\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_approved":
|
||||
case 'plan_approved':
|
||||
// Show when plan is manually approved
|
||||
if ("hasEdits" in event) {
|
||||
if ('hasEdits' in event) {
|
||||
newContent = event.hasEdits
|
||||
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
|
||||
: `\n✅ Plan approved - continuing to implementation...\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_auto_approved":
|
||||
case 'plan_auto_approved':
|
||||
// Show when plan is auto-approved
|
||||
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
|
||||
break;
|
||||
case "plan_revision_requested":
|
||||
case 'plan_revision_requested': {
|
||||
// Show when user requests plan revision
|
||||
if ("planVersion" in event) {
|
||||
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
|
||||
if ('planVersion' in event) {
|
||||
const revisionEvent = event as Extract<
|
||||
AutoModeEvent,
|
||||
{ type: 'plan_revision_requested' }
|
||||
>;
|
||||
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_task_started":
|
||||
}
|
||||
case 'auto_mode_task_started': {
|
||||
// Show when a task starts
|
||||
if ("taskId" in event && "taskDescription" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||
if ('taskId' in event && 'taskDescription' in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
|
||||
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_task_complete":
|
||||
}
|
||||
case 'auto_mode_task_complete': {
|
||||
// Show task completion progress
|
||||
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||
if ('taskId' in event && 'tasksCompleted' in event && 'tasksTotal' in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
|
||||
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_phase_complete":
|
||||
}
|
||||
case 'auto_mode_phase_complete': {
|
||||
// Show phase completion for full mode
|
||||
if ("phaseNumber" in event) {
|
||||
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
|
||||
if ('phaseNumber' in event) {
|
||||
const phaseEvent = event as Extract<
|
||||
AutoModeEvent,
|
||||
{ type: 'auto_mode_phase_complete' }
|
||||
>;
|
||||
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_feature_complete":
|
||||
const emoji = event.passes ? "✅" : "⚠️";
|
||||
}
|
||||
case 'auto_mode_feature_complete': {
|
||||
const emoji = event.passes ? '✅' : '⚠️';
|
||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||
|
||||
// Close the modal when the feature is verified (passes = true)
|
||||
@@ -239,6 +237,7 @@ export function AgentOutputModal({
|
||||
}, 1500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
@@ -267,20 +266,15 @@ export function AgentOutputModal({
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check if a number key (0-9) was pressed without modifiers
|
||||
if (
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
/^[0-9]$/.test(event.key)
|
||||
) {
|
||||
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
onNumberKeyPress(event.key);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, onNumberKeyPress]);
|
||||
|
||||
@@ -293,19 +287,18 @@ export function AgentOutputModal({
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== "verified" &&
|
||||
featureStatus !== "waiting_approval" && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
)}
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
)}
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("parsed")}
|
||||
onClick={() => setViewMode('parsed')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "parsed"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
viewMode === 'parsed'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
data-testid="view-mode-parsed"
|
||||
>
|
||||
@@ -313,11 +306,11 @@ export function AgentOutputModal({
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("changes")}
|
||||
onClick={() => setViewMode('changes')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "changes"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
viewMode === 'changes'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
data-testid="view-mode-changes"
|
||||
>
|
||||
@@ -325,11 +318,11 @@ export function AgentOutputModal({
|
||||
Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("raw")}
|
||||
onClick={() => setViewMode('raw')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "raw"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
viewMode === 'raw'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
data-testid="view-mode-raw"
|
||||
>
|
||||
@@ -353,7 +346,7 @@ export function AgentOutputModal({
|
||||
className="flex-shrink-0 mx-1"
|
||||
/>
|
||||
|
||||
{viewMode === "changes" ? (
|
||||
{viewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
@@ -386,19 +379,17 @@ 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" ? (
|
||||
) : viewMode === 'parsed' ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
? 'Auto-scrolling enabled'
|
||||
: 'Scroll to bottom to enable auto-scroll'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,30 +6,29 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
} 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 { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { modelSupportsThinking } from "@/lib/utils";
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import {
|
||||
Feature,
|
||||
AgentModel,
|
||||
@@ -38,7 +36,7 @@ import {
|
||||
AIProfile,
|
||||
useAppStore,
|
||||
PlanningMode,
|
||||
} from "@/store/app-store";
|
||||
} from '@/store/app-store';
|
||||
import {
|
||||
ModelSelector,
|
||||
ThinkingLevelSelector,
|
||||
@@ -47,14 +45,14 @@ import {
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
} from "../shared";
|
||||
} from '../shared';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DependencyTreeDialog } from "./dependency-tree-dialog";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||
|
||||
interface EditFeatureDialogProps {
|
||||
feature: Feature | null;
|
||||
@@ -104,16 +102,19 @@ export function EditFeatureDialog({
|
||||
// If feature has no branchName, default to using current branch
|
||||
return !feature?.branchName;
|
||||
});
|
||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
||||
useState<ImagePreviewMap>(() => new Map());
|
||||
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");
|
||||
'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);
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
@@ -135,33 +136,31 @@ export function EditFeatureDialog({
|
||||
if (!editingFeature) return;
|
||||
|
||||
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
||||
const isBranchSelectorEnabled = editingFeature.status === "backlog";
|
||||
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
|
||||
if (
|
||||
useWorktrees &&
|
||||
isBranchSelectorEnabled &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
) {
|
||||
toast.error("Please select a branch name");
|
||||
toast.error('Please select a branch name');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
||||
selectedModel
|
||||
)
|
||||
? editingFeature.thinkingLevel ?? "none"
|
||||
: "none";
|
||||
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
||||
? (editingFeature.thinkingLevel ?? 'none')
|
||||
: 'none';
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch
|
||||
? (currentBranch || "")
|
||||
: editingFeature.branchName || "";
|
||||
? currentBranch || ''
|
||||
: editingFeature.branchName || '';
|
||||
|
||||
const updates = {
|
||||
title: editingFeature.title ?? "",
|
||||
title: editingFeature.title ?? '',
|
||||
category: editingFeature.category,
|
||||
description: editingFeature.description,
|
||||
steps: editingFeature.steps,
|
||||
@@ -192,16 +191,11 @@ export function EditFeatureDialog({
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
model,
|
||||
thinkingLevel: modelSupportsThinking(model)
|
||||
? editingFeature.thinkingLevel
|
||||
: "none",
|
||||
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (
|
||||
model: AgentModel,
|
||||
thinkingLevel: ThinkingLevel
|
||||
) => {
|
||||
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||
if (!editingFeature) return;
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
@@ -224,16 +218,14 @@ export function EditFeatureDialog({
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setEditingFeature((prev) =>
|
||||
prev ? { ...prev, description: enhancedText } : prev
|
||||
);
|
||||
toast.success("Description enhanced!");
|
||||
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
|
||||
toast.success('Description enhanced!');
|
||||
} else {
|
||||
toast.error(result?.error || "Failed to enhance description");
|
||||
toast.error(result?.error || 'Failed to enhance description');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Enhancement failed:", error);
|
||||
toast.error("Failed to enhance description");
|
||||
console.error('Enhancement failed:', error);
|
||||
toast.error('Failed to enhance description');
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
@@ -267,10 +259,7 @@ 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"
|
||||
>
|
||||
<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" />
|
||||
@@ -287,10 +276,7 @@ export function EditFeatureDialog({
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
@@ -318,7 +304,7 @@ export function EditFeatureDialog({
|
||||
<Label htmlFor="edit-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={editingFeature.title ?? ""}
|
||||
value={editingFeature.title ?? ''}
|
||||
onChange={(e) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
@@ -332,38 +318,25 @@ export function EditFeatureDialog({
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[180px] justify-between"
|
||||
>
|
||||
{enhancementMode === "improve" && "Improve Clarity"}
|
||||
{enhancementMode === "technical" && "Add Technical Details"}
|
||||
{enhancementMode === "simplify" && "Simplify"}
|
||||
{enhancementMode === "acceptance" &&
|
||||
"Add Acceptance Criteria"}
|
||||
<Button variant="outline" size="sm" className="w-[180px] justify-between">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("improve")}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("technical")}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("simplify")}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("acceptance")}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -400,7 +373,7 @@ export function EditFeatureDialog({
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={editingFeature.branchName ?? ""}
|
||||
branchName={editingFeature.branchName ?? ''}
|
||||
onBranchNameChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
@@ -410,7 +383,7 @@ export function EditFeatureDialog({
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
disabled={editingFeature.status !== 'backlog'}
|
||||
testIdPrefix="edit-feature"
|
||||
/>
|
||||
)}
|
||||
@@ -429,17 +402,12 @@ export function EditFeatureDialog({
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<TabsContent
|
||||
value="model"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Show Advanced Options Toggle */}
|
||||
{showProfilesOnly && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Simple Mode Active
|
||||
</p>
|
||||
<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>
|
||||
@@ -447,13 +415,11 @@ export function EditFeatureDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
||||
}
|
||||
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
|
||||
data-testid="edit-show-advanced-options-toggle"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -461,29 +427,28 @@ export function EditFeatureDialog({
|
||||
{/* Quick Select Profile Section */}
|
||||
<ProfileQuickSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={editingFeature.model ?? "opus"}
|
||||
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
|
||||
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" />
|
||||
)}
|
||||
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Claude Models Section */}
|
||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<>
|
||||
<ModelSelector
|
||||
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
|
||||
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
testIdPrefix="edit-model-select"
|
||||
/>
|
||||
{editModelAllowsThinking && (
|
||||
<ThinkingLevelSelector
|
||||
selectedLevel={editingFeature.thinkingLevel ?? "none"}
|
||||
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
|
||||
onLevelSelect={(level) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
@@ -515,13 +480,9 @@ export function EditFeatureDialog({
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={editingFeature.skipTests ?? false}
|
||||
onSkipTestsChange={(skipTests) =>
|
||||
setEditingFeature({ ...editingFeature, skipTests })
|
||||
}
|
||||
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
|
||||
steps={editingFeature.steps}
|
||||
onStepsChange={(steps) =>
|
||||
setEditingFeature({ ...editingFeature, steps })
|
||||
}
|
||||
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
|
||||
testIdPrefix="edit"
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -541,12 +502,12 @@ export function EditFeatureDialog({
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleUpdate}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={!!editingFeature}
|
||||
data-testid="confirm-edit-feature"
|
||||
disabled={
|
||||
useWorktrees &&
|
||||
editingFeature.status === "backlog" &&
|
||||
editingFeature.status === 'backlog' &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Feature,
|
||||
FeatureImage,
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
ThinkingLevel,
|
||||
PlanningMode,
|
||||
useAppStore,
|
||||
} from "@/store/app-store";
|
||||
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
import { truncateDescription } from "@/lib/utils";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
} from '@/store/app-store';
|
||||
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { truncateDescription } from '@/lib/utils';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
|
||||
interface UseBoardActionsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -20,10 +20,7 @@ interface UseBoardActionsProps {
|
||||
runningAutoTasks: string[];
|
||||
loadFeatures: () => Promise<void>;
|
||||
persistFeatureCreate: (feature: Feature) => Promise<void>;
|
||||
persistFeatureUpdate: (
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
) => Promise<void>;
|
||||
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
|
||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||
saveCategory: (category: string) => Promise<void>;
|
||||
setEditingFeature: (feature: Feature | null) => void;
|
||||
@@ -41,6 +38,7 @@ interface UseBoardActionsProps {
|
||||
outputFeature: Feature | null;
|
||||
projectPath: string | null;
|
||||
onWorktreeCreated?: () => void;
|
||||
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
|
||||
}
|
||||
|
||||
@@ -68,6 +66,7 @@ export function useBoardActions({
|
||||
outputFeature,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
}: UseBoardActionsProps) {
|
||||
const {
|
||||
@@ -111,33 +110,34 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.create) {
|
||||
const result = await api.worktree.create(
|
||||
currentProject.path,
|
||||
finalBranchName
|
||||
);
|
||||
if (result.success) {
|
||||
const result = await api.worktree.create(currentProject.path, finalBranchName);
|
||||
if (result.success && result.worktree) {
|
||||
console.log(
|
||||
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||
result.worktree?.isNew ? "created" : "already exists"
|
||||
result.worktree?.isNew ? 'created' : 'already exists'
|
||||
}`
|
||||
);
|
||||
// Auto-select the worktree when creating a feature for it
|
||||
onWorktreeAutoSelect?.({
|
||||
path: result.worktree.path,
|
||||
branch: result.worktree.branch,
|
||||
});
|
||||
// Refresh worktree list in UI
|
||||
onWorktreeCreated?.();
|
||||
} else {
|
||||
} else if (!result.success) {
|
||||
console.error(
|
||||
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||
result.error
|
||||
);
|
||||
toast.error("Failed to create worktree", {
|
||||
description: result.error || "An error occurred",
|
||||
toast.error('Failed to create worktree', {
|
||||
description: result.error || 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error creating worktree:", error);
|
||||
toast.error("Failed to create worktree", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
console.error('[Board] Error creating worktree:', error);
|
||||
toast.error('Failed to create worktree', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export function useBoardActions({
|
||||
...featureData,
|
||||
title: featureData.title,
|
||||
titleGenerating: needsTitleGeneration,
|
||||
status: "backlog" as const,
|
||||
status: 'backlog' as const,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
@@ -161,10 +161,14 @@ export function useBoardActions({
|
||||
if (needsTitleGeneration) {
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
api.features.generateTitle(featureData.description)
|
||||
api.features
|
||||
.generateTitle(featureData.description)
|
||||
.then((result) => {
|
||||
if (result.success && result.title) {
|
||||
const titleUpdates = { title: result.title, titleGenerating: false };
|
||||
const titleUpdates = {
|
||||
title: result.title,
|
||||
titleGenerating: false,
|
||||
};
|
||||
updateFeature(createdFeature.id, titleUpdates);
|
||||
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||
} else {
|
||||
@@ -175,7 +179,7 @@ export function useBoardActions({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Board] Error generating title:", error);
|
||||
console.error('[Board] Error generating title:', error);
|
||||
// Clear generating flag on error
|
||||
const titleUpdates = { titleGenerating: false };
|
||||
updateFeature(createdFeature.id, titleUpdates);
|
||||
@@ -184,7 +188,17 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
},
|
||||
[addFeature, persistFeatureCreate, persistFeatureUpdate, updateFeature, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
|
||||
[
|
||||
addFeature,
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
updateFeature,
|
||||
saveCategory,
|
||||
useWorktrees,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
]
|
||||
);
|
||||
|
||||
const handleUpdateFeature = useCallback(
|
||||
@@ -213,14 +227,11 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.create) {
|
||||
const result = await api.worktree.create(
|
||||
currentProject.path,
|
||||
finalBranchName
|
||||
);
|
||||
const result = await api.worktree.create(currentProject.path, finalBranchName);
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||
result.worktree?.isNew ? "created" : "already exists"
|
||||
result.worktree?.isNew ? 'created' : 'already exists'
|
||||
}`
|
||||
);
|
||||
// Refresh worktree list in UI
|
||||
@@ -230,16 +241,15 @@ export function useBoardActions({
|
||||
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||
result.error
|
||||
);
|
||||
toast.error("Failed to create worktree", {
|
||||
description: result.error || "An error occurred",
|
||||
toast.error('Failed to create worktree', {
|
||||
description: result.error || 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error creating worktree:", error);
|
||||
toast.error("Failed to create worktree", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
console.error('[Board] Error creating worktree:', error);
|
||||
toast.error('Failed to create worktree', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -257,7 +267,15 @@ export function useBoardActions({
|
||||
}
|
||||
setEditingFeature(null);
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
|
||||
[
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
saveCategory,
|
||||
setEditingFeature,
|
||||
useWorktrees,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -270,15 +288,13 @@ export function useBoardActions({
|
||||
if (isRunning) {
|
||||
try {
|
||||
await autoMode.stopFeature(featureId);
|
||||
toast.success("Agent stopped", {
|
||||
description: `Stopped and deleted: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
toast.success('Agent stopped', {
|
||||
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature before delete:", error);
|
||||
toast.error("Failed to stop agent", {
|
||||
description: "The feature will still be deleted.",
|
||||
console.error('[Board] Error stopping feature before delete:', error);
|
||||
toast.error('Failed to stop agent', {
|
||||
description: 'The feature will still be deleted.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -291,17 +307,11 @@ export function useBoardActions({
|
||||
await api.deleteFile(imagePathObj.path);
|
||||
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Board] Failed to delete image ${imagePathObj.path}:`,
|
||||
error
|
||||
);
|
||||
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Board] Error deleting images for feature ${featureId}:`,
|
||||
error
|
||||
);
|
||||
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +328,7 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
console.error('Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,15 +342,15 @@ export function useBoardActions({
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
"[Board] Feature run started successfully, branch:",
|
||||
feature.branchName || "default"
|
||||
'[Board] Feature run started successfully, branch:',
|
||||
feature.branchName || 'default'
|
||||
);
|
||||
} else {
|
||||
console.error("[Board] Failed to run feature:", result.error);
|
||||
console.error('[Board] Failed to run feature:', result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error running feature:", error);
|
||||
console.error('[Board] Error running feature:', error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
@@ -350,9 +360,9 @@ export function useBoardActions({
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!autoMode.canStartNewTask) {
|
||||
toast.error("Concurrency limit reached", {
|
||||
toast.error('Concurrency limit reached', {
|
||||
description: `You can only have ${autoMode.maxConcurrency} task${
|
||||
autoMode.maxConcurrency > 1 ? "s" : ""
|
||||
autoMode.maxConcurrency > 1 ? 's' : ''
|
||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||
});
|
||||
return false;
|
||||
@@ -367,22 +377,22 @@ export function useBoardActions({
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||
})
|
||||
.join(", ");
|
||||
.join(', ');
|
||||
|
||||
toast.warning("Starting feature with incomplete dependencies", {
|
||||
toast.warning('Starting feature with incomplete dependencies', {
|
||||
description: `This feature depends on: ${depDescriptions}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
// Must await to ensure feature status is persisted before starting agent
|
||||
await persistFeatureUpdate(feature.id, updates);
|
||||
console.log("[Board] Feature moved to in_progress, starting agent...");
|
||||
console.log('[Board] Feature moved to in_progress, starting agent...');
|
||||
await handleRunFeature(feature);
|
||||
return true;
|
||||
},
|
||||
@@ -403,23 +413,20 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
console.error('Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.verifyFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature verification started successfully");
|
||||
console.log('[Board] Feature verification started successfully');
|
||||
} else {
|
||||
console.error("[Board] Failed to verify feature:", result.error);
|
||||
console.error('[Board] Failed to verify feature:', result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error verifying feature:", error);
|
||||
console.error('[Board] Error verifying feature:', error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
@@ -433,7 +440,7 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
console.error('Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -444,13 +451,13 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature resume started successfully");
|
||||
console.log('[Board] Feature resume started successfully');
|
||||
} else {
|
||||
console.error("[Board] Failed to resume feature:", result.error);
|
||||
console.error('[Board] Failed to resume feature:', result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error resuming feature:", error);
|
||||
console.error('[Board] Error resuming feature:', error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
@@ -459,15 +466,13 @@ export function useBoardActions({
|
||||
|
||||
const handleManualVerify = useCallback(
|
||||
(feature: Feature) => {
|
||||
moveFeature(feature.id, "verified");
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: "verified",
|
||||
status: 'verified',
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Marked as verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
toast.success('Feature verified', {
|
||||
description: `Marked as verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[moveFeature, persistFeatureUpdate]
|
||||
@@ -476,15 +481,13 @@ export function useBoardActions({
|
||||
const handleMoveBackToInProgress = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to In Progress: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
toast.info('Feature moved back', {
|
||||
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
@@ -493,16 +496,11 @@ export function useBoardActions({
|
||||
const handleOpenFollowUp = useCallback(
|
||||
(feature: Feature) => {
|
||||
setFollowUpFeature(feature);
|
||||
setFollowUpPrompt("");
|
||||
setFollowUpPrompt('');
|
||||
setFollowUpImagePaths([]);
|
||||
setShowFollowUpDialog(true);
|
||||
},
|
||||
[
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setShowFollowUpDialog,
|
||||
]
|
||||
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
|
||||
);
|
||||
|
||||
const handleSendFollowUp = useCallback(async () => {
|
||||
@@ -513,15 +511,15 @@ export function useBoardActions({
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.followUpFeature) {
|
||||
console.error("Follow-up feature API not available");
|
||||
toast.error("Follow-up not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
console.error('Follow-up feature API not available');
|
||||
toast.error('Follow-up not available', {
|
||||
description: 'This feature is not available in the current version.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
justFinishedAt: undefined,
|
||||
};
|
||||
@@ -530,14 +528,12 @@ export function useBoardActions({
|
||||
|
||||
setShowFollowUpDialog(false);
|
||||
setFollowUpFeature(null);
|
||||
setFollowUpPrompt("");
|
||||
setFollowUpPrompt('');
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
|
||||
toast.success("Follow-up started", {
|
||||
description: `Continuing work on: ${truncateDescription(
|
||||
featureDescription
|
||||
)}`,
|
||||
toast.success('Follow-up started', {
|
||||
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
|
||||
});
|
||||
|
||||
const imagePaths = followUpImagePaths.map((img) => img.path);
|
||||
@@ -551,10 +547,9 @@ export function useBoardActions({
|
||||
// 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",
|
||||
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();
|
||||
});
|
||||
@@ -580,10 +575,9 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.commitFeature) {
|
||||
console.error("Commit feature API not available");
|
||||
toast.error("Commit not available", {
|
||||
description:
|
||||
"This feature is not available in the current version.",
|
||||
console.error('Commit feature API not available');
|
||||
toast.error('Commit not available', {
|
||||
description: 'This feature is not available in the current version.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -596,38 +590,29 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
moveFeature(feature.id, "verified");
|
||||
persistFeatureUpdate(feature.id, { status: "verified" });
|
||||
toast.success("Feature committed", {
|
||||
description: `Committed and verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, { status: 'verified' });
|
||||
toast.success('Feature committed', {
|
||||
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
// Refresh worktree selector to update commit counts
|
||||
onWorktreeCreated?.();
|
||||
} else {
|
||||
console.error("[Board] Failed to commit feature:", result.error);
|
||||
toast.error("Failed to commit feature", {
|
||||
description: result.error || "An error occurred",
|
||||
console.error('[Board] Failed to commit feature:', result.error);
|
||||
toast.error('Failed to commit feature', {
|
||||
description: result.error || 'An error occurred',
|
||||
});
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error committing feature:", error);
|
||||
toast.error("Failed to commit feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
console.error('[Board] Error committing feature:', error);
|
||||
toast.error('Failed to commit feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[
|
||||
currentProject,
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
loadFeatures,
|
||||
onWorktreeCreated,
|
||||
]
|
||||
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleMergeFeature = useCallback(
|
||||
@@ -637,37 +622,32 @@ export function useBoardActions({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.mergeFeature) {
|
||||
console.error("Worktree API not available");
|
||||
toast.error("Merge not available", {
|
||||
description:
|
||||
"This feature is not available in the current version.",
|
||||
console.error('Worktree API not available');
|
||||
toast.error('Merge not available', {
|
||||
description: 'This feature is not available in the current version.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.mergeFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
|
||||
|
||||
if (result.success) {
|
||||
await loadFeatures();
|
||||
toast.success("Feature merged", {
|
||||
toast.success('Feature merged', {
|
||||
description: `Changes merged to main branch: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to merge feature:", result.error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description: result.error || "An error occurred",
|
||||
console.error('[Board] Failed to merge feature:', result.error);
|
||||
toast.error('Failed to merge feature', {
|
||||
description: result.error || 'An error occurred',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error merging feature:", error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
console.error('[Board] Error merging feature:', error);
|
||||
toast.error('Failed to merge feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -677,12 +657,12 @@ export function useBoardActions({
|
||||
const handleCompleteFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: "completed" as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
toast.success("Feature completed", {
|
||||
toast.success('Feature completed', {
|
||||
description: `Archived: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
@@ -692,15 +672,13 @@ export function useBoardActions({
|
||||
const handleUnarchiveFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: "verified" as const,
|
||||
status: 'verified' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
toast.success("Feature restored", {
|
||||
description: `Moved back to verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
toast.success('Feature restored', {
|
||||
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
@@ -716,7 +694,7 @@ export function useBoardActions({
|
||||
|
||||
const handleOutputModalNumberKeyPress = useCallback(
|
||||
(key: string) => {
|
||||
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
|
||||
const index = key === '0' ? 9 : parseInt(key, 10) - 1;
|
||||
const targetFeature = inProgressFeaturesForShortcuts[index];
|
||||
|
||||
if (!targetFeature) {
|
||||
@@ -729,12 +707,7 @@ export function useBoardActions({
|
||||
setOutputFeature(targetFeature);
|
||||
}
|
||||
},
|
||||
[
|
||||
inProgressFeaturesForShortcuts,
|
||||
outputFeature?.id,
|
||||
setShowOutputModal,
|
||||
setOutputFeature,
|
||||
]
|
||||
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
|
||||
);
|
||||
|
||||
const handleForceStopFeature = useCallback(
|
||||
@@ -743,9 +716,9 @@ export function useBoardActions({
|
||||
await autoMode.stopFeature(feature.id);
|
||||
|
||||
const targetStatus =
|
||||
feature.skipTests && feature.status === "waiting_approval"
|
||||
? "waiting_approval"
|
||||
: "backlog";
|
||||
feature.skipTests && feature.status === 'waiting_approval'
|
||||
? 'waiting_approval'
|
||||
: 'backlog';
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
@@ -753,21 +726,18 @@ export function useBoardActions({
|
||||
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
}
|
||||
|
||||
toast.success("Agent stopped", {
|
||||
toast.success('Agent stopped', {
|
||||
description:
|
||||
targetStatus === "waiting_approval"
|
||||
targetStatus === 'waiting_approval'
|
||||
? `Stopped commit - returned to waiting approval: ${truncateDescription(
|
||||
feature.description
|
||||
)}`
|
||||
: `Stopped working on: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
: `Stopped working on: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature:", error);
|
||||
toast.error("Failed to stop agent", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
console.error('[Board] Error stopping feature:', error);
|
||||
toast.error('Failed to stop agent', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -777,25 +747,21 @@ export function useBoardActions({
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This ensures "G" only starts features from the filtered list
|
||||
const primaryBranch = projectPath
|
||||
? getPrimaryWorktreeBranch(projectPath)
|
||||
: null;
|
||||
const primaryBranch = projectPath ? getPrimaryWorktreeBranch(projectPath) : null;
|
||||
const backlogFeatures = features.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
// Determine the feature's branch (default to primary branch if not set)
|
||||
const featureBranch = f.branchName || primaryBranch || "main";
|
||||
const featureBranch = f.branchName || primaryBranch || 'main';
|
||||
|
||||
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
|
||||
// show features with no branch or primary branch
|
||||
if (
|
||||
!currentWorktreeBranch ||
|
||||
(projectPath &&
|
||||
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
|
||||
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
|
||||
) {
|
||||
return (
|
||||
!f.branchName ||
|
||||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
|
||||
!f.branchName || (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -803,13 +769,11 @@ export function useBoardActions({
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const availableSlots =
|
||||
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
||||
const availableSlots = useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast.error("Concurrency limit reached", {
|
||||
description:
|
||||
"Wait for a task to complete or increase the concurrency limit.",
|
||||
toast.error('Concurrency limit reached', {
|
||||
description: 'Wait for a task to complete or increase the concurrency limit.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -817,12 +781,11 @@ export function useBoardActions({
|
||||
if (backlogFeatures.length === 0) {
|
||||
const isOnPrimaryBranch =
|
||||
!currentWorktreeBranch ||
|
||||
(projectPath &&
|
||||
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
|
||||
toast.info("Backlog empty", {
|
||||
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
|
||||
toast.info('Backlog empty', {
|
||||
description: !isOnPrimaryBranch
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
: 'No features in backlog to start.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -852,9 +815,9 @@ export function useBoardActions({
|
||||
});
|
||||
|
||||
if (!featureToStart) {
|
||||
toast.info("No eligible features", {
|
||||
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.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -874,7 +837,7 @@ export function useBoardActions({
|
||||
]);
|
||||
|
||||
const handleArchiveAllVerified = useCallback(async () => {
|
||||
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||
const verifiedFeatures = features.filter((f) => f.status === 'verified');
|
||||
|
||||
for (const feature of verifiedFeatures) {
|
||||
const isRunning = runningAutoTasks.includes(feature.id);
|
||||
@@ -882,30 +845,21 @@ export function useBoardActions({
|
||||
try {
|
||||
await autoMode.stopFeature(feature.id);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Board] Error stopping feature before archive:",
|
||||
error
|
||||
);
|
||||
console.error('[Board] Error stopping feature before archive:', error);
|
||||
}
|
||||
}
|
||||
// Archive the feature by setting status to completed
|
||||
const updates = {
|
||||
status: "completed" as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
}
|
||||
|
||||
toast.success("All verified features archived", {
|
||||
toast.success('All verified features archived', {
|
||||
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
||||
});
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
autoMode,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
|
||||
|
||||
return {
|
||||
handleAddFeature,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Feature, useAppStore } from "@/store/app-store";
|
||||
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
type ColumnId = Feature['status'];
|
||||
|
||||
interface UseBoardColumnFeaturesProps {
|
||||
features: Feature[];
|
||||
@@ -87,7 +87,7 @@ export function useBoardColumnFeatures({
|
||||
|
||||
// Filter all items by worktree, including backlog
|
||||
// This ensures backlog items with a branch assigned only show in that branch
|
||||
if (status === "backlog") {
|
||||
if (status === 'backlog') {
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
@@ -136,7 +136,14 @@ export function useBoardColumnFeatures({
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
]);
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
(columnId: ColumnId) => {
|
||||
@@ -147,7 +154,7 @@ export function useBoardColumnFeatures({
|
||||
|
||||
// Memoize completed features for the archive modal
|
||||
const completedFeatures = useMemo(() => {
|
||||
return features.filter((f) => f.status === "completed");
|
||||
return features.filter((f) => f.status === 'completed');
|
||||
}, [features]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { KanbanColumn, KanbanCard } from "./components";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { FastForward, Lightbulb, Archive } from "lucide-react";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
|
||||
import { COLUMNS, ColumnId } from "./constants";
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { KanbanColumn, KanbanCard } from './components';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { FastForward, Lightbulb, Archive } from 'lucide-react';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
import { COLUMNS, ColumnId } from './constants';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
@@ -93,10 +85,7 @@ export function KanbanBoard({
|
||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
@@ -118,8 +107,7 @@ export function KanbanBoard({
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
headerAction={
|
||||
column.id === "verified" &&
|
||||
columnFeatures.length > 0 ? (
|
||||
column.id === 'verified' && columnFeatures.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -130,7 +118,7 @@ export function KanbanBoard({
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Archive All
|
||||
</Button>
|
||||
) : column.id === "backlog" ? (
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -175,9 +163,8 @@ export function KanbanBoard({
|
||||
{columnFeatures.map((feature, index) => {
|
||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === "in_progress" && index < 10) {
|
||||
shortcutKey =
|
||||
index === 9 ? "0" : String(index + 1);
|
||||
if (column.id === 'in_progress' && index < 10) {
|
||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||
}
|
||||
return (
|
||||
<KanbanCard
|
||||
@@ -190,30 +177,19 @@ export function KanbanBoard({
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() =>
|
||||
onMoveBackToInProgress(feature)
|
||||
}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onCommit={() => onCommit(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(
|
||||
feature.id
|
||||
)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={
|
||||
backgroundSettings.cardGlassmorphism
|
||||
}
|
||||
cardBorderEnabled={
|
||||
backgroundSettings.cardBorderEnabled
|
||||
}
|
||||
cardBorderOpacity={
|
||||
backgroundSettings.cardBorderOpacity
|
||||
}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -226,7 +202,7 @@ export function KanbanBoard({
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
|
||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
|
||||
@@ -1,66 +1,50 @@
|
||||
import { AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||
import {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
|
||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
|
||||
export type ModelOption = {
|
||||
id: AgentModel;
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
provider: "claude";
|
||||
provider: 'claude';
|
||||
};
|
||||
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: "haiku",
|
||||
label: "Claude Haiku",
|
||||
description: "Fast and efficient for simple tasks.",
|
||||
badge: "Speed",
|
||||
provider: "claude",
|
||||
id: 'haiku',
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: "sonnet",
|
||||
label: "Claude Sonnet",
|
||||
description: "Balanced performance with strong reasoning.",
|
||||
badge: "Balanced",
|
||||
provider: "claude",
|
||||
id: 'sonnet',
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: "opus",
|
||||
label: "Claude Opus",
|
||||
description: "Most capable model for complex work.",
|
||||
badge: "Premium",
|
||||
provider: "claude",
|
||||
id: 'opus',
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
provider: 'claude',
|
||||
},
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"ultrathink",
|
||||
];
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||
|
||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: "None",
|
||||
low: "Low",
|
||||
medium: "Med",
|
||||
high: "High",
|
||||
ultrathink: "Ultra",
|
||||
none: 'None',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra',
|
||||
};
|
||||
|
||||
// Profile icon mapping
|
||||
export const PROFILE_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
|
||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -91,93 +85,88 @@ export function WorktreeTab({
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? "open";
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||
const prStateClasses = (() => {
|
||||
// When selected (active tab), use high contrast solid background (paper-like)
|
||||
if (isSelected) {
|
||||
return "bg-background text-foreground border-transparent shadow-sm";
|
||||
return 'bg-background text-foreground border-transparent shadow-sm';
|
||||
}
|
||||
|
||||
// When not selected, use the colored variants
|
||||
switch (prState) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
|
||||
case "draft":
|
||||
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
|
||||
case "merged":
|
||||
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
|
||||
case "closed":
|
||||
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
|
||||
case 'open':
|
||||
case 'reopened':
|
||||
return 'bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25';
|
||||
case 'draft':
|
||||
return 'bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25';
|
||||
case 'merged':
|
||||
return 'bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25';
|
||||
case 'closed':
|
||||
return 'bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25';
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
|
||||
return 'bg-muted text-muted-foreground border-border/60 hover:bg-muted/80';
|
||||
}
|
||||
})();
|
||||
|
||||
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
|
||||
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ''}`;
|
||||
|
||||
// Helper to get status icon color for the selected state
|
||||
const getStatusColorClass = () => {
|
||||
if (!isSelected) return "";
|
||||
if (!isSelected) return '';
|
||||
switch (prState) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
return "text-emerald-600 dark:text-emerald-500";
|
||||
case "draft":
|
||||
return "text-amber-600 dark:text-amber-500";
|
||||
case "merged":
|
||||
return "text-purple-600 dark:text-purple-500";
|
||||
case "closed":
|
||||
return "text-rose-600 dark:text-rose-500";
|
||||
case 'open':
|
||||
case 'reopened':
|
||||
return 'text-emerald-600 dark:text-emerald-500';
|
||||
case 'draft':
|
||||
return 'text-amber-600 dark:text-amber-500';
|
||||
case 'merged':
|
||||
return 'text-purple-600 dark:text-purple-500';
|
||||
case 'closed':
|
||||
return 'text-rose-600 dark:text-rose-500';
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
prBadge = (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
|
||||
"appearance-none cursor-pointer hover:opacity-80 active:opacity-70", // Reset button appearance but keep cursor, add hover/active states
|
||||
'ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background',
|
||||
'cursor-pointer hover:opacity-80 active:opacity-70',
|
||||
prStateClasses
|
||||
)}
|
||||
style={{
|
||||
// Override any inherited button styles
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
title={`${prLabel} - Click to open`}
|
||||
aria-label={`${prLabel} - Click to open pull request`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering worktree selection
|
||||
if (worktree.pr?.url) {
|
||||
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent event from bubbling to parent button
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (worktree.pr?.url) {
|
||||
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
|
||||
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
|
||||
<GitPullRequest className={cn('w-3 h-3', getStatusColorClass())} aria-hidden="true" />
|
||||
<span aria-hidden="true" className={isSelected ? 'text-foreground font-semibold' : ''}>
|
||||
PR #{worktree.pr.number}
|
||||
</span>
|
||||
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
|
||||
<span className={cn('capitalize', getStatusColorClass())} aria-hidden="true">
|
||||
{prState}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,21 +175,21 @@ export function WorktreeTab({
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
'h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title="Click to preview main"
|
||||
title={`Click to preview ${worktree.branch}`}
|
||||
aria-label={worktree.branch}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -211,18 +200,23 @@ export function WorktreeTab({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||
isSelected
|
||||
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
isSelected
|
||||
? 'bg-amber-500 text-amber-950 border-amber-400'
|
||||
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||
)}
|
||||
>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? "!"}
|
||||
{changedFilesCount ?? '!'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||
<p>
|
||||
{changedFilesCount ?? 'Some'} uncommitted file
|
||||
{changedFilesCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -245,26 +239,24 @@ export function WorktreeTab({
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
!worktree.hasWorktree && !isSelected && "opacity-70"
|
||||
'h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary',
|
||||
!worktree.hasWorktree && !isSelected && 'opacity-70'
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title={
|
||||
worktree.hasWorktree
|
||||
? "Click to switch to this worktree's branch"
|
||||
: "Click to switch to this branch"
|
||||
: 'Click to switch to this branch'
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -275,18 +267,23 @@ export function WorktreeTab({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||
isSelected
|
||||
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
isSelected
|
||||
? 'bg-amber-500 text-amber-950 border-amber-400'
|
||||
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||
)}
|
||||
>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? "!"}
|
||||
{changedFilesCount ?? '!'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||
<p>
|
||||
{changedFilesCount ?? 'Some'} uncommitted file
|
||||
{changedFilesCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -297,13 +294,13 @@ export function WorktreeTab({
|
||||
|
||||
{isDevServerRunning && (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
"text-green-500"
|
||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary',
|
||||
'text-green-500'
|
||||
)}
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
interface UseWorktreesOptions {
|
||||
projectPath: string;
|
||||
@@ -11,7 +10,11 @@ interface UseWorktreesOptions {
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
}
|
||||
|
||||
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
|
||||
export function useWorktrees({
|
||||
projectPath,
|
||||
refreshTrigger = 0,
|
||||
onRemovedWorktrees,
|
||||
}: UseWorktreesOptions) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
|
||||
@@ -20,29 +23,37 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
console.warn("Worktree API not available");
|
||||
return;
|
||||
const fetchWorktrees = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
if (!projectPath) return;
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
console.warn('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
// Return removed worktrees so they can be handled by the caller
|
||||
return result.removedWorktrees;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch worktrees:', error);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
// Return removed worktrees so they can be handled by the caller
|
||||
return result.removedWorktrees;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, setWorktreesInStore]);
|
||||
},
|
||||
[projectPath, setWorktreesInStore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees();
|
||||
@@ -58,30 +69,38 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
||||
|
||||
// Use a ref to track the current worktree to avoid running validation
|
||||
// when selection changes (which could cause a race condition with stale worktrees list)
|
||||
const currentWorktreeRef = useRef(currentWorktree);
|
||||
useEffect(() => {
|
||||
currentWorktreeRef.current = currentWorktree;
|
||||
}, [currentWorktree]);
|
||||
|
||||
// Validation effect: only runs when worktrees list changes (not on selection change)
|
||||
// This prevents a race condition where the selection is reset because the
|
||||
// local worktrees state hasn't been updated yet from the async fetch
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
const current = currentWorktreeRef.current;
|
||||
const currentPath = current?.path;
|
||||
const currentWorktreeExists =
|
||||
currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
if (current == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
// Find the primary worktree and get its branch name
|
||||
// Fallback to "main" only if worktrees haven't loaded yet
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
const mainBranch = mainWorktree?.branch || 'main';
|
||||
setCurrentWorktree(projectPath, null, mainBranch);
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
}, [worktrees, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
setCurrentWorktree(
|
||||
projectPath,
|
||||
worktree.isMain ? null : worktree.path,
|
||||
worktree.branch
|
||||
);
|
||||
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
||||
},
|
||||
[projectPath, setCurrentWorktree]
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitBranch, Plus, RefreshCw, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn, pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -10,10 +11,10 @@ import {
|
||||
useWorktreeActions,
|
||||
useDefaultEditor,
|
||||
useRunningFeatures,
|
||||
} from "./hooks";
|
||||
import { WorktreeTab } from "./components";
|
||||
} from './hooks';
|
||||
import { WorktreeTab } from './components';
|
||||
|
||||
const WORKTREE_PANEL_COLLAPSED_KEY = "worktree-panel-collapsed";
|
||||
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -85,20 +86,38 @@ export function WorktreePanel({
|
||||
|
||||
// Collapse state with localStorage persistence
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem(WORKTREE_PANEL_COLLAPSED_KEY);
|
||||
return saved === "true";
|
||||
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
|
||||
return saved === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
|
||||
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
|
||||
|
||||
// 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
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Get the currently selected worktree for collapsed view
|
||||
const selectedWorktree = worktrees.find((w) => {
|
||||
if (currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null) {
|
||||
if (
|
||||
currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
) {
|
||||
return w.isMain;
|
||||
}
|
||||
return pathsEqual(w.path, currentWorktreePath);
|
||||
@@ -106,9 +125,7 @@ export function WorktreePanel({
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
return worktree.isMain
|
||||
? currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
};
|
||||
|
||||
@@ -125,9 +142,8 @@ export function WorktreePanel({
|
||||
}
|
||||
};
|
||||
|
||||
if (!useWorktreesEnabled) {
|
||||
return null;
|
||||
}
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
// Collapsed view - just show current branch and toggle
|
||||
if (isCollapsed) {
|
||||
@@ -140,16 +156,14 @@ export function WorktreePanel({
|
||||
onClick={toggleCollapsed}
|
||||
title="Expand worktree panel"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
<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>
|
||||
<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 ?? "!"}
|
||||
{selectedWorktree.changedFilesCount ?? '!'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,86 +180,139 @@ export function WorktreePanel({
|
||||
onClick={toggleCollapsed}
|
||||
title="Collapse worktree panel"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{worktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -14,17 +13,16 @@ import {
|
||||
Save,
|
||||
Upload,
|
||||
File,
|
||||
X,
|
||||
BookOpen,
|
||||
EditIcon,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
} from '@/hooks/use-keyboard-shortcuts';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,15 +30,15 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Markdown } from "../ui/markdown";
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Markdown } from '../ui/markdown';
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
type: "text" | "image";
|
||||
type: 'text' | 'image';
|
||||
content?: string;
|
||||
path: string;
|
||||
}
|
||||
@@ -53,17 +51,15 @@ export function ContextView() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [editedContent, setEditedContent] = useState("");
|
||||
const [editedContent, setEditedContent] = useState('');
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameFileName, setRenameFileName] = useState("");
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [newFileContent, setNewFileContent] = useState("");
|
||||
const [renameFileName, setRenameFileName] = useState('');
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [newFileType, setNewFileType] = useState<'text' | 'image'>('text');
|
||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(null);
|
||||
const [newFileContent, setNewFileContent] = useState('');
|
||||
const [isDropHovering, setIsDropHovering] = useState(false);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
|
||||
@@ -73,7 +69,7 @@ export function ContextView() {
|
||||
{
|
||||
key: shortcuts.addContextFile,
|
||||
action: () => setIsAddDialogOpen(true),
|
||||
description: "Add new context file",
|
||||
description: 'Add new context file',
|
||||
},
|
||||
],
|
||||
[shortcuts]
|
||||
@@ -87,22 +83,14 @@ export function ContextView() {
|
||||
}, [currentProject]);
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
||||
return ext === ".md" || ext === ".markdown";
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
};
|
||||
|
||||
// Determine if a file is an image based on extension
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
const imageExtensions = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".bmp",
|
||||
];
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
@@ -125,13 +113,13 @@ export function ContextView() {
|
||||
.filter((entry) => entry.isFile)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: isImageFile(entry.name) ? "image" : "text",
|
||||
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||
path: `${contextPath}/${entry.name}`,
|
||||
}));
|
||||
setContextFiles(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load context files:", error);
|
||||
console.error('Failed to load context files:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -152,7 +140,7 @@ export function ContextView() {
|
||||
setHasChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load file content:", error);
|
||||
console.error('Failed to load file content:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -176,7 +164,7 @@ export function ContextView() {
|
||||
setSelectedFile({ ...selectedFile, content: editedContent });
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save file:", error);
|
||||
console.error('Failed to save file:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -198,32 +186,32 @@ export function ContextView() {
|
||||
let filename = newFileName.trim();
|
||||
|
||||
// Add default extension if not provided
|
||||
if (newFileType === "text" && !filename.includes(".")) {
|
||||
filename += ".md";
|
||||
if (newFileType === 'text' && !filename.includes('.')) {
|
||||
filename += '.md';
|
||||
}
|
||||
|
||||
const filePath = `${contextPath}/${filename}`;
|
||||
|
||||
if (newFileType === "image" && uploadedImageData) {
|
||||
if (newFileType === 'image' && uploadedImageData) {
|
||||
// Write image data
|
||||
await api.writeFile(filePath, uploadedImageData);
|
||||
} else {
|
||||
// Write text file with content (or empty if no content)
|
||||
await api.writeFile(filePath, newFileContent);
|
||||
}
|
||||
|
||||
|
||||
// Only reload files on success
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to add file:", error);
|
||||
console.error('Failed to add file:', error);
|
||||
// Optionally show error toast to user here
|
||||
} finally {
|
||||
// Close dialog and reset state
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setNewFileType("text");
|
||||
setNewFileName('');
|
||||
setNewFileType('text');
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setNewFileContent('');
|
||||
setIsDropHovering(false);
|
||||
}
|
||||
};
|
||||
@@ -238,11 +226,11 @@ export function ContextView() {
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setEditedContent("");
|
||||
setEditedContent('');
|
||||
setHasChanges(false);
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error);
|
||||
console.error('Failed to delete file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,14 +252,14 @@ export function ContextView() {
|
||||
// Check if file with new name already exists
|
||||
const exists = await api.exists(newPath);
|
||||
if (exists) {
|
||||
console.error("A file with this name already exists");
|
||||
console.error('A file with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read current file content
|
||||
const result = await api.readFile(selectedFile.path);
|
||||
if (!result.success || result.content === undefined) {
|
||||
console.error("Failed to read file for rename");
|
||||
console.error('Failed to read file for rename');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,7 +270,7 @@ export function ContextView() {
|
||||
await api.deleteFile(selectedFile.path);
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName("");
|
||||
setRenameFileName('');
|
||||
|
||||
// Reload files and select the renamed file
|
||||
await loadContextFiles();
|
||||
@@ -290,13 +278,13 @@ export function ContextView() {
|
||||
// Update selected file with new name and path
|
||||
const renamedFile: ContextFile = {
|
||||
name: newName,
|
||||
type: isImageFile(newName) ? "image" : "text",
|
||||
type: isImageFile(newName) ? 'image' : 'text',
|
||||
path: newPath,
|
||||
content: result.content,
|
||||
};
|
||||
setSelectedFile(renamedFile);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename file:", error);
|
||||
console.error('Failed to rename file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -352,9 +340,7 @@ export function ContextView() {
|
||||
};
|
||||
|
||||
// Handle drag and drop for .txt and .md files in the add context dialog textarea
|
||||
const handleTextAreaDrop = async (
|
||||
e: React.DragEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
const handleTextAreaDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
@@ -366,8 +352,8 @@ export function ContextView() {
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
// Only accept .txt and .md files
|
||||
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
|
||||
console.warn("Only .txt and .md files are supported for drag and drop");
|
||||
if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) {
|
||||
console.warn('Only .txt and .md files are supported for drag and drop');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,20 +395,14 @@ export function ContextView() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="context-view-loading"
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="context-view"
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="context-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -462,10 +442,7 @@ export function ContextView() {
|
||||
Context Files ({contextFiles.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-2"
|
||||
data-testid="context-file-list"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-2" data-testid="context-file-list">
|
||||
{contextFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
||||
@@ -481,10 +458,10 @@ export function ContextView() {
|
||||
<div
|
||||
key={file.path}
|
||||
className={cn(
|
||||
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
|
||||
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors',
|
||||
selectedFile?.path === file.path
|
||||
? "bg-primary/20 text-foreground border border-primary/30"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
? 'bg-primary/20 text-foreground border border-primary/30'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -492,7 +469,7 @@ export function ContextView() {
|
||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||
data-testid={`context-file-${file.name}`}
|
||||
>
|
||||
{file.type === "image" ? (
|
||||
{file.type === 'image' ? (
|
||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||
@@ -525,38 +502,35 @@ export function ContextView() {
|
||||
{/* File toolbar */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFile.type === "image" ? (
|
||||
{selectedFile.type === 'image' ? (
|
||||
<ImageIcon className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{selectedFile.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedFile.type === "text" &&
|
||||
isMarkdownFile(selectedFile.name) && (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
data-testid="toggle-preview-mode"
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<EditIcon className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === "text" && (
|
||||
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
data-testid="toggle-preview-mode"
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<EditIcon className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === 'text' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveFile}
|
||||
@@ -564,7 +538,7 @@ export function ContextView() {
|
||||
data-testid="save-context-file"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -581,7 +555,7 @@ export function ContextView() {
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{selectedFile.type === "image" ? (
|
||||
{selectedFile.type === 'image' ? (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-card rounded-lg"
|
||||
data-testid="image-preview"
|
||||
@@ -614,12 +588,8 @@ export function ContextView() {
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-foreground-secondary">
|
||||
Select a file to view or edit
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Or drop files here to add them
|
||||
</p>
|
||||
<p className="text-foreground-secondary">Select a file to view or edit</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">Or drop files here to add them</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -634,25 +604,23 @@ export function ContextView() {
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new text or image file to the context.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Add a new text or image file to the context.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={newFileType === "text" ? "default" : "outline"}
|
||||
variant={newFileType === 'text' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setNewFileType("text")}
|
||||
onClick={() => setNewFileType('text')}
|
||||
data-testid="add-text-type"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Text
|
||||
</Button>
|
||||
<Button
|
||||
variant={newFileType === "image" ? "default" : "outline"}
|
||||
variant={newFileType === 'image' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setNewFileType("image")}
|
||||
onClick={() => setNewFileType('image')}
|
||||
data-testid="add-image-type"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
@@ -666,20 +634,18 @@ export function ContextView() {
|
||||
id="filename"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder={
|
||||
newFileType === "text" ? "context.md" : "image.png"
|
||||
}
|
||||
placeholder={newFileType === 'text' ? 'context.md' : 'image.png'}
|
||||
data-testid="new-file-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newFileType === "text" && (
|
||||
{newFileType === 'text' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-content">Context Content</Label>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-lg transition-colors",
|
||||
isDropHovering && "ring-2 ring-primary"
|
||||
'relative rounded-lg transition-colors',
|
||||
isDropHovering && 'ring-2 ring-primary'
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
@@ -691,8 +657,8 @@ export function ContextView() {
|
||||
onDragLeave={handleTextAreaDragLeave}
|
||||
placeholder="Enter context content here or drag & drop a .txt or .md file..."
|
||||
className={cn(
|
||||
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
|
||||
isDropHovering && "border-primary bg-primary/10"
|
||||
'w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent',
|
||||
isDropHovering && 'border-primary bg-primary/10'
|
||||
)}
|
||||
spellCheck={false}
|
||||
data-testid="new-file-content"
|
||||
@@ -701,9 +667,7 @@ export function ContextView() {
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
|
||||
<div className="flex flex-col items-center text-primary">
|
||||
<Upload className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm font-medium">
|
||||
Drop .txt or .md file here
|
||||
</span>
|
||||
<span className="text-sm font-medium">Drop .txt or .md file here</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -714,7 +678,7 @@ export function ContextView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newFileType === "image" && (
|
||||
{newFileType === 'image' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Upload Image</Label>
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
|
||||
@@ -740,9 +704,7 @@ export function ContextView() {
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{uploadedImageData
|
||||
? "Click to change"
|
||||
: "Click to upload"}
|
||||
{uploadedImageData ? 'Click to change' : 'Click to upload'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -754,9 +716,9 @@ export function ContextView() {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setNewFileName('');
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setNewFileContent('');
|
||||
setIsDropHovering(false);
|
||||
}}
|
||||
>
|
||||
@@ -764,11 +726,8 @@ export function ContextView() {
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleAddFile}
|
||||
disabled={
|
||||
!newFileName.trim() ||
|
||||
(newFileType === "image" && !uploadedImageData)
|
||||
}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
disabled={!newFileName.trim() || (newFileType === 'image' && !uploadedImageData)}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={isAddDialogOpen}
|
||||
data-testid="confirm-add-file"
|
||||
>
|
||||
@@ -784,15 +743,11 @@ export function ContextView() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedFile?.name}"? This
|
||||
action cannot be undone.
|
||||
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteDialogOpen(false)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -812,9 +767,7 @@ export function ContextView() {
|
||||
<DialogContent data-testid="rename-context-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for "{selectedFile?.name}".
|
||||
</DialogDescription>
|
||||
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
@@ -826,7 +779,7 @@ export function ContextView() {
|
||||
placeholder="Enter new filename"
|
||||
data-testid="rename-file-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && renameFileName.trim()) {
|
||||
if (e.key === 'Enter' && renameFileName.trim()) {
|
||||
handleRenameFile();
|
||||
}
|
||||
}}
|
||||
@@ -838,7 +791,7 @@ export function ContextView() {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName("");
|
||||
setRenameFileName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Bot,
|
||||
Send,
|
||||
User,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
|
||||
interface InterviewMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
@@ -39,62 +30,90 @@ interface InterviewState {
|
||||
// Interview questions flow
|
||||
const INTERVIEW_QUESTIONS = [
|
||||
{
|
||||
id: "project-description",
|
||||
question: "What do you want to build?",
|
||||
hint: "Describe your project idea in a few sentences",
|
||||
field: "projectDescription" as const,
|
||||
id: 'project-description',
|
||||
question: 'What do you want to build?',
|
||||
hint: 'Describe your project idea in a few sentences',
|
||||
field: 'projectDescription' as const,
|
||||
},
|
||||
{
|
||||
id: "tech-stack",
|
||||
question: "What tech stack would you like to use?",
|
||||
hint: "e.g., React, Next.js, Node.js, Python, etc.",
|
||||
field: "techStack" as const,
|
||||
id: 'tech-stack',
|
||||
question: 'What tech stack would you like to use?',
|
||||
hint: 'e.g., React, Next.js, Node.js, Python, etc.',
|
||||
field: 'techStack' as const,
|
||||
},
|
||||
{
|
||||
id: "core-features",
|
||||
question: "What are the core features you want to include?",
|
||||
hint: "List the main functionalities your app should have",
|
||||
field: "features" as const,
|
||||
id: 'core-features',
|
||||
question: 'What are the core features you want to include?',
|
||||
hint: 'List the main functionalities your app should have',
|
||||
field: 'features' as const,
|
||||
},
|
||||
{
|
||||
id: "additional",
|
||||
question: "Any additional requirements or preferences?",
|
||||
hint: "Design preferences, integrations, deployment needs, etc.",
|
||||
field: "additionalNotes" as const,
|
||||
id: 'additional',
|
||||
question: 'Any additional requirements or preferences?',
|
||||
hint: 'Design preferences, integrations, deployment needs, etc.',
|
||||
field: 'additionalNotes' as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function InterviewView() {
|
||||
const { addProject, setCurrentProject, setAppSpec } =
|
||||
useAppStore();
|
||||
const { addProject, setCurrentProject, setAppSpec } = useAppStore();
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
const navigate = useNavigate();
|
||||
const [input, setInput] = useState("");
|
||||
const [input, setInput] = useState('');
|
||||
const [messages, setMessages] = useState<InterviewMessage[]>([]);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [interviewData, setInterviewData] = useState<InterviewState>({
|
||||
projectName: "",
|
||||
projectDescription: "",
|
||||
projectName: '',
|
||||
projectDescription: '',
|
||||
techStack: [],
|
||||
features: [],
|
||||
additionalNotes: "",
|
||||
additionalNotes: '',
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [generatedSpec, setGeneratedSpec] = useState<string | null>(null);
|
||||
const [projectPath, setProjectPath] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectPath, setProjectPath] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [showProjectSetup, setShowProjectSetup] = useState(false);
|
||||
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Default parent directory using workspace config utility
|
||||
useEffect(() => {
|
||||
if (projectPath) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadWorkspaceDir = async () => {
|
||||
try {
|
||||
const defaultDir = await getDefaultWorkspaceDirectory();
|
||||
|
||||
if (!isMounted || projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultDir) {
|
||||
setProjectPath(defaultDir);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load default workspace directory:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadWorkspaceDir();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
// Initialize with first question
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
const welcomeMessage: InterviewMessage = {
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `Hello! I'm here to help you plan your new project. Let's go through a few questions to understand what you want to build.\n\n**${INTERVIEW_QUESTIONS[0].question}**\n\n_${INTERVIEW_QUESTIONS[0].hint}_`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -111,7 +130,7 @@ export function InterviewView() {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTo({
|
||||
top: messagesContainerRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
@@ -135,7 +154,7 @@ export function InterviewView() {
|
||||
|
||||
const userMessage: InterviewMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -147,25 +166,20 @@ export function InterviewView() {
|
||||
if (currentQuestion) {
|
||||
setInterviewData((prev) => {
|
||||
const newData = { ...prev };
|
||||
if (
|
||||
currentQuestion.field === "techStack" ||
|
||||
currentQuestion.field === "features"
|
||||
) {
|
||||
if (currentQuestion.field === 'techStack' || currentQuestion.field === 'features') {
|
||||
// Parse comma-separated values into array
|
||||
newData[currentQuestion.field] = input
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
(newData as Record<string, string | string[]>)[
|
||||
currentQuestion.field
|
||||
] = input;
|
||||
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
setInput("");
|
||||
setInput('');
|
||||
|
||||
// Move to next question or complete
|
||||
const nextIndex = currentQuestionIndex + 1;
|
||||
@@ -175,7 +189,7 @@ export function InterviewView() {
|
||||
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
|
||||
const assistantMessage: InterviewMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
role: 'assistant',
|
||||
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -185,34 +199,30 @@ export function InterviewView() {
|
||||
// All questions answered - generate spec
|
||||
const summaryMessage: InterviewMessage = {
|
||||
id: `assistant-summary-${Date.now()}`,
|
||||
role: "assistant",
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Perfect! I have all the information I need. Now let me generate your project specification...",
|
||||
'Perfect! I have all the information I need. Now let me generate your project specification...',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, summaryMessage]);
|
||||
generateSpec({
|
||||
...interviewData,
|
||||
projectDescription:
|
||||
currentQuestionIndex === 0
|
||||
? input
|
||||
: interviewData.projectDescription,
|
||||
projectDescription: currentQuestionIndex === 0 ? input : interviewData.projectDescription,
|
||||
techStack:
|
||||
currentQuestionIndex === 1
|
||||
? input
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: interviewData.techStack,
|
||||
features:
|
||||
currentQuestionIndex === 2
|
||||
? input
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: interviewData.features,
|
||||
additionalNotes:
|
||||
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
|
||||
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
@@ -234,7 +244,7 @@ export function InterviewView() {
|
||||
|
||||
const completionMessage: InterviewMessage = {
|
||||
id: `assistant-complete-${Date.now()}`,
|
||||
role: "assistant",
|
||||
role: 'assistant',
|
||||
content: `I've generated a draft project specification based on our conversation!\n\nPlease provide a project name and choose where to save your project, then click "Create Project" to get started.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -243,15 +253,15 @@ export function InterviewView() {
|
||||
|
||||
const generateAppSpec = (data: InterviewState): string => {
|
||||
const projectName = data.projectDescription
|
||||
.split(" ")
|
||||
.split(' ')
|
||||
.slice(0, 3)
|
||||
.join("-")
|
||||
.join('-')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
|
||||
return `<project_specification>
|
||||
<project_name>${projectName || "my-project"}</project_name>
|
||||
<project_name>${projectName || 'my-project'}</project_name>
|
||||
|
||||
<overview>
|
||||
${data.projectDescription}
|
||||
@@ -260,25 +270,21 @@ export function InterviewView() {
|
||||
<technology_stack>
|
||||
${
|
||||
data.techStack.length > 0
|
||||
? data.techStack
|
||||
.map((tech) => `<technology>${tech}</technology>`)
|
||||
.join("\n ")
|
||||
: "<!-- Define your tech stack -->"
|
||||
? data.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')
|
||||
: '<!-- Define your tech stack -->'
|
||||
}
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
${
|
||||
data.features.length > 0
|
||||
? data.features
|
||||
.map((feature) => `<capability>${feature}</capability>`)
|
||||
.join("\n ")
|
||||
: "<!-- List core features -->"
|
||||
? data.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')
|
||||
: '<!-- List core features -->'
|
||||
}
|
||||
</core_capabilities>
|
||||
|
||||
<additional_requirements>
|
||||
${data.additionalNotes || "None specified"}
|
||||
${data.additionalNotes || 'None specified'}
|
||||
</additional_requirements>
|
||||
|
||||
<development_guidelines>
|
||||
@@ -292,13 +298,14 @@ export function InterviewView() {
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const selectedPath = await openFileBrowser({
|
||||
title: "Select Base Directory",
|
||||
description:
|
||||
"Choose the parent directory where your new project will be created",
|
||||
title: 'Select Base Directory',
|
||||
description: 'Choose the parent directory where your new project will be created',
|
||||
initialPath: projectPath || undefined,
|
||||
});
|
||||
|
||||
if (selectedPath) {
|
||||
setProjectPath(selectedPath);
|
||||
saveLastProjectDirectory(selectedPath);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -308,48 +315,46 @@ export function InterviewView() {
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
saveLastProjectDirectory(projectPath);
|
||||
const api = getElectronAPI();
|
||||
// Use platform-specific path separator
|
||||
const pathSep =
|
||||
typeof window !== "undefined" && (window as any).electronAPI
|
||||
? navigator.platform.indexOf("Win") !== -1
|
||||
? "\\"
|
||||
: "/"
|
||||
: "/";
|
||||
typeof window !== 'undefined' && (window as any).electronAPI
|
||||
? navigator.platform.indexOf('Win') !== -1
|
||||
? '\\'
|
||||
: '/'
|
||||
: '/';
|
||||
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
|
||||
|
||||
// Create project directory
|
||||
const mkdirResult = await api.mkdir(fullProjectPath);
|
||||
if (!mkdirResult.success) {
|
||||
toast.error("Failed to create project directory", {
|
||||
description: mkdirResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to create project directory', {
|
||||
description: mkdirResult.error || 'Unknown error occurred',
|
||||
});
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write app_spec.txt with generated content
|
||||
await api.writeFile(
|
||||
`${fullProjectPath}/.automaker/app_spec.txt`,
|
||||
generatedSpec
|
||||
);
|
||||
await api.writeFile(`${fullProjectPath}/.automaker/app_spec.txt`, generatedSpec);
|
||||
|
||||
// Create initial feature in the features folder
|
||||
const initialFeature: Feature = {
|
||||
id: crypto.randomUUID(),
|
||||
category: "Core",
|
||||
description: "Initial project setup",
|
||||
status: "backlog" as const,
|
||||
category: 'Core',
|
||||
description: 'Initial project setup',
|
||||
status: 'backlog' as const,
|
||||
steps: [
|
||||
"Step 1: Review app_spec.txt",
|
||||
"Step 2: Set up development environment",
|
||||
"Step 3: Start implementing features",
|
||||
'Step 1: Review app_spec.txt',
|
||||
'Step 2: Set up development environment',
|
||||
'Step 3: Start implementing features',
|
||||
],
|
||||
skipTests: true,
|
||||
};
|
||||
|
||||
if (!api.features) {
|
||||
throw new Error("Features API not available");
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
await api.features.create(fullProjectPath, initialFeature);
|
||||
|
||||
@@ -367,27 +372,24 @@ export function InterviewView() {
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project:", error);
|
||||
console.error('Failed to create project:', error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate({ to: "/" });
|
||||
navigate({ to: '/' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col content-bg min-h-0"
|
||||
data-testid="interview-view"
|
||||
>
|
||||
<div className="flex-1 flex flex-col content-bg min-h-0" data-testid="interview-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -405,10 +407,8 @@ export function InterviewView() {
|
||||
<h1 className="text-xl font-bold">New Project Interview</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isComplete
|
||||
? "Specification generated!"
|
||||
: `Question ${currentQuestionIndex + 1} of ${
|
||||
INTERVIEW_QUESTIONS.length
|
||||
}`}
|
||||
? 'Specification generated!'
|
||||
: `Question ${currentQuestionIndex + 1} of ${INTERVIEW_QUESTIONS.length}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,18 +419,16 @@ export function InterviewView() {
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
'w-2 h-2 rounded-full transition-colors',
|
||||
index < currentQuestionIndex
|
||||
? "bg-green-500"
|
||||
? 'bg-green-500'
|
||||
: index === currentQuestionIndex
|
||||
? "bg-primary"
|
||||
: "bg-zinc-700"
|
||||
? 'bg-primary'
|
||||
: 'bg-zinc-700'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{isComplete && (
|
||||
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
|
||||
)}
|
||||
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -443,18 +441,15 @@ export function InterviewView() {
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
message.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
className={cn('flex gap-3', message.role === 'user' && 'flex-row-reverse')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
||||
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
|
||||
'w-8 h-8 rounded-full flex items-center justify-center shrink-0',
|
||||
message.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
{message.role === 'assistant' ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
@@ -462,28 +457,24 @@ export function InterviewView() {
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-[80%]",
|
||||
message.role === "user"
|
||||
? "bg-transparent border border-primary text-foreground"
|
||||
: "border border-primary/30 bg-card"
|
||||
'max-w-[80%]',
|
||||
message.role === 'user'
|
||||
? 'bg-transparent border border-primary text-foreground'
|
||||
: 'border border-primary/30 bg-card'
|
||||
)}
|
||||
>
|
||||
<CardContent className="px-3 py-2">
|
||||
{message.role === "assistant" ? (
|
||||
{message.role === 'assistant' ? (
|
||||
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-1",
|
||||
message.role === "user"
|
||||
? "text-muted-foreground"
|
||||
: "text-primary/70"
|
||||
'text-xs mt-1',
|
||||
message.role === 'user' ? 'text-muted-foreground' : 'text-primary/70'
|
||||
)}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
@@ -502,9 +493,7 @@ export function InterviewView() {
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<span className="text-sm text-primary">
|
||||
Generating specification...
|
||||
</span>
|
||||
<span className="text-sm text-primary">Generating specification...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -514,10 +503,7 @@ export function InterviewView() {
|
||||
{/* Project Setup Form */}
|
||||
{showProjectSetup && (
|
||||
<div className="mt-6">
|
||||
<Card
|
||||
className="bg-zinc-900/50 border-white/10"
|
||||
data-testid="project-setup-form"
|
||||
>
|
||||
<Card className="bg-zinc-900/50 border-white/10" data-testid="project-setup-form">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
@@ -526,10 +512,7 @@ export function InterviewView() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="project-name"
|
||||
className="text-sm font-medium text-zinc-300"
|
||||
>
|
||||
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
|
||||
Project Name
|
||||
</label>
|
||||
<Input
|
||||
@@ -543,10 +526,7 @@ export function InterviewView() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="project-path"
|
||||
className="text-sm font-medium text-zinc-300"
|
||||
>
|
||||
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
|
||||
Parent Directory
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
import { useCliStatus, useSettingsView } from "./settings-view/hooks";
|
||||
import { NAV_ITEMS } from "./settings-view/config/navigation";
|
||||
import { SettingsHeader } from "./settings-view/components/settings-header";
|
||||
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
|
||||
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||
import { TerminalSection } from "./settings-view/terminal/terminal-section";
|
||||
import { AudioSection } from "./settings-view/audio/audio-section";
|
||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
|
||||
import type {
|
||||
Project as SettingsProject,
|
||||
Theme,
|
||||
} from "./settings-view/shared/types";
|
||||
import type { Project as ElectronProject } from "@/lib/electron";
|
||||
import { useCliStatus, useSettingsView } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
|
||||
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||
import { ClaudeUsageSection } from './settings-view/api-keys/claude-usage-section';
|
||||
import { ClaudeCliStatus } from './settings-view/cli-status/claude-cli-status';
|
||||
import { AIEnhancementSection } from './settings-view/ai-enhancement';
|
||||
import { AppearanceSection } from './settings-view/appearance/appearance-section';
|
||||
import { TerminalSection } from './settings-view/terminal/terminal-section';
|
||||
import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
|
||||
export function SettingsView() {
|
||||
const {
|
||||
@@ -47,12 +44,17 @@ export function SettingsView() {
|
||||
defaultAIProfileId,
|
||||
setDefaultAIProfileId,
|
||||
aiProfiles,
|
||||
apiKeys,
|
||||
} = useAppStore();
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (
|
||||
project: ElectronProject | null
|
||||
): SettingsProject | null => {
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
@@ -79,8 +81,7 @@ export function SettingsView() {
|
||||
};
|
||||
|
||||
// Use CLI status hook
|
||||
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
|
||||
useCliStatus();
|
||||
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } = useCliStatus();
|
||||
|
||||
// Use settings view navigation hook
|
||||
const { activeView, navigateTo } = useSettingsView();
|
||||
@@ -91,17 +92,20 @@ export function SettingsView() {
|
||||
// Render the active section based on current view
|
||||
const renderActiveSection = () => {
|
||||
switch (activeView) {
|
||||
case "claude":
|
||||
case 'claude':
|
||||
return (
|
||||
<ClaudeCliStatus
|
||||
status={claudeCliStatus}
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<ClaudeCliStatus
|
||||
status={claudeCliStatus}
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
{showUsageTracking && <ClaudeUsageSection />}
|
||||
</div>
|
||||
);
|
||||
case "ai-enhancement":
|
||||
case 'ai-enhancement':
|
||||
return <AIEnhancementSection />;
|
||||
case "appearance":
|
||||
case 'appearance':
|
||||
return (
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
@@ -109,22 +113,17 @@ export function SettingsView() {
|
||||
onThemeChange={handleSetTheme}
|
||||
/>
|
||||
);
|
||||
case "terminal":
|
||||
case 'terminal':
|
||||
return <TerminalSection />;
|
||||
case "keyboard":
|
||||
case 'keyboard':
|
||||
return (
|
||||
<KeyboardShortcutsSection
|
||||
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
||||
/>
|
||||
<KeyboardShortcutsSection onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)} />
|
||||
);
|
||||
case "audio":
|
||||
case 'audio':
|
||||
return (
|
||||
<AudioSection
|
||||
muteDoneSound={muteDoneSound}
|
||||
onMuteDoneSoundChange={setMuteDoneSound}
|
||||
/>
|
||||
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
|
||||
);
|
||||
case "defaults":
|
||||
case 'defaults':
|
||||
return (
|
||||
<FeatureDefaultsSection
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
@@ -144,7 +143,7 @@ export function SettingsView() {
|
||||
onDefaultAIProfileIdChange={setDefaultAIProfileId}
|
||||
/>
|
||||
);
|
||||
case "danger":
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
@@ -157,10 +156,7 @@ export function SettingsView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="settings-view"
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
|
||||
@@ -181,10 +177,7 @@ export function SettingsView() {
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog
|
||||
open={showKeyboardMapDialog}
|
||||
onOpenChange={setShowKeyboardMapDialog}
|
||||
/>
|
||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Terminal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { ClaudeAuthStatus } from "@/store/setup-store";
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CheckCircle2, AlertCircle, Info, Terminal } from 'lucide-react';
|
||||
import type { ClaudeAuthStatus } from '@/store/setup-store';
|
||||
|
||||
interface AuthenticationStatusDisplayProps {
|
||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||
@@ -39,35 +33,29 @@ export function AuthenticationStatusDisplay({
|
||||
<div className="p-3 rounded-lg bg-card border border-border">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Terminal className="w-4 h-4 text-brand-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Claude (Anthropic)
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">Claude (Anthropic)</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs min-h-12">
|
||||
{claudeAuthStatus?.authenticated ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||
<span className="text-green-400 font-medium">
|
||||
Authenticated
|
||||
</span>
|
||||
<span className="text-green-400 font-medium">Authenticated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{claudeAuthStatus.method === "oauth_token"
|
||||
? "Using stored OAuth token (subscription)"
|
||||
: claudeAuthStatus.method === "api_key_env"
|
||||
? "Using ANTHROPIC_API_KEY"
|
||||
: claudeAuthStatus.method === "api_key"
|
||||
? "Using stored API key"
|
||||
: claudeAuthStatus.method === "credentials_file"
|
||||
? "Using credentials file"
|
||||
: claudeAuthStatus.method === "cli_authenticated"
|
||||
? "Using Claude CLI authentication"
|
||||
: `Using ${
|
||||
claudeAuthStatus.method || "detected"
|
||||
} authentication`}
|
||||
{claudeAuthStatus.method === 'oauth_token'
|
||||
? 'Using stored OAuth token (subscription)'
|
||||
: claudeAuthStatus.method === 'api_key_env'
|
||||
? 'Using ANTHROPIC_API_KEY'
|
||||
: claudeAuthStatus.method === 'api_key'
|
||||
? 'Using stored API key'
|
||||
: claudeAuthStatus.method === 'credentials_file'
|
||||
? 'Using credentials file'
|
||||
: claudeAuthStatus.method === 'cli_authenticated'
|
||||
? 'Using Claude CLI authentication'
|
||||
: `Using ${claudeAuthStatus.method || 'detected'} authentication`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ClaudeUsageSection() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<div className="w-5 h-5 rounded-full bg-green-500/50" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Claude Usage Tracking
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Track your Claude Code usage limits. Uses the Claude CLI for data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Info about CLI requirement */}
|
||||
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
|
||||
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-1">
|
||||
<li>Install Claude Code CLI if not already installed</li>
|
||||
<li>
|
||||
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
|
||||
authenticate
|
||||
</li>
|
||||
<li>Usage data will be fetched automatically every ~minute</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useApiKeyManagement } from './use-api-key-management';
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ApiKeyField } from './api-key-field';
|
||||
export { ApiKeysSection } from './api-keys-section';
|
||||
export { AuthenticationStatusDisplay } from './authentication-status-display';
|
||||
export { SecurityNotice } from './security-notice';
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Palette } from "lucide-react";
|
||||
import { themeOptions } from "@/config/theme-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Theme, Project } from "../shared/types";
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Palette, Moon, Sun } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Theme, Project } from '../shared/types';
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
@@ -15,13 +16,17 @@ export function AppearanceSection({
|
||||
currentProject,
|
||||
onThemeChange,
|
||||
}: AppearanceSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||
|
||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl overflow-hidden",
|
||||
"border border-border/50",
|
||||
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
|
||||
"shadow-sm shadow-black/5"
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
@@ -37,43 +42,69 @@ export function AppearanceSection({
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<Label className="text-foreground font-medium">
|
||||
Theme{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
|
||||
</span>
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">
|
||||
Theme{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
|
||||
</span>
|
||||
</Label>
|
||||
{/* Dark/Light Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||
<button
|
||||
onClick={() => setActiveTab('dark')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||
activeTab === 'dark'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('light')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||
activeTab === 'light'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{themeOptions.map(({ value, label, Icon, testId }) => {
|
||||
{themesToShow.map(({ value, label, Icon, testId, color }) => {
|
||||
const isActive = effectiveTheme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onThemeChange(value)}
|
||||
className={cn(
|
||||
"group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl",
|
||||
"text-sm font-medium transition-all duration-200 ease-out",
|
||||
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
isActive
|
||||
? [
|
||||
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
|
||||
"border-2 border-brand-500/40",
|
||||
"text-foreground",
|
||||
"shadow-md shadow-brand-500/10",
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
"bg-accent/30 hover:bg-accent/50",
|
||||
"border border-border/50 hover:border-border",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"hover:shadow-sm",
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
"hover:scale-[1.02] active:scale-[0.98]"
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 transition-all duration-200",
|
||||
isActive ? "text-brand-500" : "group-hover:text-brand-400"
|
||||
)} />
|
||||
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AppearanceSection } from './appearance-section';
|
||||
@@ -0,0 +1 @@
|
||||
export { AudioSection } from './audio-section';
|
||||
@@ -0,0 +1 @@
|
||||
export { ClaudeCliStatus } from './claude-cli-status';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Folder, Trash2 } from "lucide-react";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import type { Project } from "@/lib/electron";
|
||||
import { Folder } from 'lucide-react';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface DeleteProjectDialogProps {
|
||||
open: boolean;
|
||||
@@ -39,18 +39,13 @@ export function DeleteProjectDialog({
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.path}
|
||||
</p>
|
||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The folder will remain on disk until you permanently delete it from
|
||||
Trash.
|
||||
The folder will remain on disk until you permanently delete it from Trash.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DeleteProjectDialog } from './delete-project-dialog';
|
||||
export { KeyboardMapDialog } from './keyboard-map-dialog';
|
||||
export { SettingsHeader } from './settings-header';
|
||||
export { SettingsNavigation } from './settings-navigation';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NAV_ITEMS } from './navigation';
|
||||
export type { NavigationItem } from './navigation';
|
||||
@@ -0,0 +1 @@
|
||||
export { DangerZoneSection } from './danger-zone-section';
|
||||
@@ -0,0 +1 @@
|
||||
export { FeatureDefaultsSection } from './feature-defaults-section';
|
||||
@@ -0,0 +1 @@
|
||||
export { KeyboardShortcutsSection } from './keyboard-shortcuts-section';
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { Theme } from '@/config/theme-options';
|
||||
export type { CliStatus, KanbanDetailLevel, Project, ApiKeys } from './types';
|
||||
@@ -1,4 +1,6 @@
|
||||
// Shared TypeScript types for settings view components
|
||||
// Theme type is now imported from the central theme-options config
|
||||
export { type Theme } from '@/config/theme-options';
|
||||
|
||||
export interface CliStatus {
|
||||
success: boolean;
|
||||
@@ -17,31 +19,13 @@ export interface CliStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type Theme =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "retro"
|
||||
| "dracula"
|
||||
| "nord"
|
||||
| "monokai"
|
||||
| "tokyonight"
|
||||
| "solarized"
|
||||
| "gruvbox"
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave"
|
||||
| "red"
|
||||
| "cream"
|
||||
| "sunset"
|
||||
| "gray";
|
||||
|
||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||
export type KanbanDetailLevel = 'minimal' | 'standard' | 'detailed';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
theme?: Theme;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeys {
|
||||
|
||||
@@ -1,86 +1,83 @@
|
||||
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { StepIndicator } from "./setup-view/components";
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { StepIndicator } from './setup-view/components';
|
||||
import {
|
||||
WelcomeStep,
|
||||
ThemeStep,
|
||||
CompleteStep,
|
||||
ClaudeSetupStep,
|
||||
GitHubSetupStep,
|
||||
} from "./setup-view/steps";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
} from './setup-view/steps';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
|
||||
// Main Setup View
|
||||
export function SetupView() {
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
completeSetup,
|
||||
setSkipClaudeSetup,
|
||||
} = useSetupStore();
|
||||
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const steps = ["welcome", "claude", "github", "complete"] as const;
|
||||
const steps = ['welcome', 'theme', 'claude', 'github', 'complete'] as const;
|
||||
type StepName = (typeof steps)[number];
|
||||
const getStepName = (): StepName => {
|
||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
||||
return "claude";
|
||||
if (currentStep === "welcome") return "welcome";
|
||||
if (currentStep === "github") return "github";
|
||||
return "complete";
|
||||
if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude';
|
||||
if (currentStep === 'welcome') return 'welcome';
|
||||
if (currentStep === 'theme') return 'theme';
|
||||
if (currentStep === 'github') return 'github';
|
||||
return 'complete';
|
||||
};
|
||||
const currentIndex = steps.indexOf(getStepName());
|
||||
|
||||
const handleNext = (from: string) => {
|
||||
console.log(
|
||||
"[Setup Flow] handleNext called from:",
|
||||
from,
|
||||
"currentStep:",
|
||||
currentStep
|
||||
);
|
||||
console.log('[Setup Flow] handleNext called from:', from, 'currentStep:', currentStep);
|
||||
switch (from) {
|
||||
case "welcome":
|
||||
console.log("[Setup Flow] Moving to claude_detect step");
|
||||
setCurrentStep("claude_detect");
|
||||
case 'welcome':
|
||||
console.log('[Setup Flow] Moving to theme step');
|
||||
setCurrentStep('theme');
|
||||
break;
|
||||
case "claude":
|
||||
console.log("[Setup Flow] Moving to github step");
|
||||
setCurrentStep("github");
|
||||
case 'theme':
|
||||
console.log('[Setup Flow] Moving to claude_detect step');
|
||||
setCurrentStep('claude_detect');
|
||||
break;
|
||||
case "github":
|
||||
console.log("[Setup Flow] Moving to complete step");
|
||||
setCurrentStep("complete");
|
||||
case 'claude':
|
||||
console.log('[Setup Flow] Moving to github step');
|
||||
setCurrentStep('github');
|
||||
break;
|
||||
case 'github':
|
||||
console.log('[Setup Flow] Moving to complete step');
|
||||
setCurrentStep('complete');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = (from: string) => {
|
||||
console.log("[Setup Flow] handleBack called from:", from);
|
||||
console.log('[Setup Flow] handleBack called from:', from);
|
||||
switch (from) {
|
||||
case "claude":
|
||||
setCurrentStep("welcome");
|
||||
case 'theme':
|
||||
setCurrentStep('welcome');
|
||||
break;
|
||||
case "github":
|
||||
setCurrentStep("claude_detect");
|
||||
case 'claude':
|
||||
setCurrentStep('theme');
|
||||
break;
|
||||
case 'github':
|
||||
setCurrentStep('claude_detect');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipClaude = () => {
|
||||
console.log("[Setup Flow] Skipping Claude setup");
|
||||
console.log('[Setup Flow] Skipping Claude setup');
|
||||
setSkipClaudeSetup(true);
|
||||
setCurrentStep("github");
|
||||
setCurrentStep('github');
|
||||
};
|
||||
|
||||
const handleSkipGithub = () => {
|
||||
console.log("[Setup Flow] Skipping GitHub setup");
|
||||
setCurrentStep("complete");
|
||||
console.log('[Setup Flow] Skipping GitHub setup');
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
console.log("[Setup Flow] handleFinish called - completing setup");
|
||||
console.log('[Setup Flow] handleFinish called - completing setup');
|
||||
completeSetup();
|
||||
console.log("[Setup Flow] Setup completed, redirecting to welcome view");
|
||||
navigate({ to: "/" });
|
||||
console.log('[Setup Flow] Setup completed, redirecting to welcome view');
|
||||
navigate({ to: '/' });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -90,50 +87,42 @@ export function SetupView() {
|
||||
<div className="px-8 py-4">
|
||||
<div className="flex items-center gap-3 titlebar-no-drag">
|
||||
<img src="/logo.png" alt="Automaker" className="w-8 h-8" />
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
Automaker Setup
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-foreground">Automaker Setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="p-8">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<StepIndicator
|
||||
currentStep={currentIndex}
|
||||
totalSteps={steps.length}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex items-center justify-center">
|
||||
<div className="w-full max-w-2xl mx-auto px-8">
|
||||
<div className="mb-8">
|
||||
<StepIndicator currentStep={currentIndex} totalSteps={steps.length} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{currentStep === 'welcome' && <WelcomeStep onNext={() => handleNext('welcome')} />}
|
||||
|
||||
{currentStep === 'theme' && (
|
||||
<ThemeStep onNext={() => handleNext('theme')} onBack={() => handleBack('theme')} />
|
||||
)}
|
||||
|
||||
{(currentStep === 'claude_detect' || currentStep === 'claude_auth') && (
|
||||
<ClaudeSetupStep
|
||||
onNext={() => handleNext('claude')}
|
||||
onBack={() => handleBack('claude')}
|
||||
onSkip={handleSkipClaude}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-8">
|
||||
{currentStep === "welcome" && (
|
||||
<WelcomeStep onNext={() => handleNext("welcome")} />
|
||||
)}
|
||||
{currentStep === 'github' && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext('github')}
|
||||
onBack={() => handleBack('github')}
|
||||
onSkip={handleSkipGithub}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStep === "claude_detect" ||
|
||||
currentStep === "claude_auth") && (
|
||||
<ClaudeSetupStep
|
||||
onNext={() => handleNext("claude")}
|
||||
onBack={() => handleBack("claude")}
|
||||
onSkip={handleSkipClaude}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "github" && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext("github")}
|
||||
onBack={() => handleBack("github")}
|
||||
onSkip={handleSkipGithub}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "complete" && (
|
||||
<CompleteStep onFinish={handleFinish} />
|
||||
)}
|
||||
</div>
|
||||
{currentStep === 'complete' && <CompleteStep onFinish={handleFinish} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
} from '@/components/ui/accordion';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
@@ -31,14 +24,13 @@ import {
|
||||
RefreshCw,
|
||||
Download,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge, TerminalOutput } from "../components";
|
||||
import { useCliStatus, useCliInstallation, useTokenSave } from "../hooks";
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { StatusBadge, TerminalOutput } from '../components';
|
||||
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
|
||||
|
||||
interface ClaudeSetupStepProps {
|
||||
onNext: () => void;
|
||||
@@ -46,17 +38,13 @@ interface ClaudeSetupStepProps {
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
type VerificationStatus = "idle" | "verifying" | "verified" | "error";
|
||||
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
|
||||
|
||||
// Claude Setup Step
|
||||
// Users can either:
|
||||
// 1. Have Claude CLI installed and authenticated (verified by running a test query)
|
||||
// 2. Provide an Anthropic API key manually
|
||||
export function ClaudeSetupStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: ClaudeSetupStepProps) {
|
||||
export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps) {
|
||||
const {
|
||||
claudeCliStatus,
|
||||
claudeAuthStatus,
|
||||
@@ -66,21 +54,16 @@ export function ClaudeSetupStep({
|
||||
} = useSetupStore();
|
||||
const { setApiKeys, apiKeys } = useAppStore();
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
|
||||
// CLI Verification state
|
||||
const [cliVerificationStatus, setCliVerificationStatus] =
|
||||
useState<VerificationStatus>("idle");
|
||||
const [cliVerificationError, setCliVerificationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
|
||||
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
|
||||
|
||||
// API Key Verification state
|
||||
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
|
||||
useState<VerificationStatus>("idle");
|
||||
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
useState<VerificationStatus>('idle');
|
||||
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(null);
|
||||
|
||||
// Delete API Key state
|
||||
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
|
||||
@@ -96,14 +79,11 @@ export function ClaudeSetupStep({
|
||||
[]
|
||||
);
|
||||
|
||||
const getStoreState = useCallback(
|
||||
() => useSetupStore.getState().claudeCliStatus,
|
||||
[]
|
||||
);
|
||||
const getStoreState = useCallback(() => useSetupStore.getState().claudeCliStatus, []);
|
||||
|
||||
// Use custom hooks
|
||||
const { isChecking, checkStatus } = useCliStatus({
|
||||
cliType: "claude",
|
||||
cliType: 'claude',
|
||||
statusApi,
|
||||
setCliStatus: setClaudeCliStatus,
|
||||
setAuthStatus: setClaudeAuthStatus,
|
||||
@@ -114,120 +94,114 @@ export function ClaudeSetupStep({
|
||||
}, [checkStatus]);
|
||||
|
||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||
cliType: "claude",
|
||||
cliType: 'claude',
|
||||
installApi,
|
||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||
onSuccess: onInstallSuccess,
|
||||
getStoreState,
|
||||
});
|
||||
|
||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave(
|
||||
{
|
||||
provider: "anthropic",
|
||||
onSuccess: () => {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "api_key",
|
||||
hasCredentialsFile: false,
|
||||
apiKeyValid: true,
|
||||
});
|
||||
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
||||
toast.success("API key saved successfully!");
|
||||
},
|
||||
}
|
||||
);
|
||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||
provider: 'anthropic',
|
||||
onSuccess: () => {
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasCredentialsFile: false,
|
||||
apiKeyValid: true,
|
||||
});
|
||||
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
||||
toast.success('API key saved successfully!');
|
||||
},
|
||||
});
|
||||
|
||||
// Verify CLI authentication by running a test query (uses CLI credentials only, not API key)
|
||||
const verifyCliAuth = useCallback(async () => {
|
||||
setCliVerificationStatus("verifying");
|
||||
setCliVerificationStatus('verifying');
|
||||
setCliVerificationError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.verifyClaudeAuth) {
|
||||
setCliVerificationStatus("error");
|
||||
setCliVerificationError("Verification API not available");
|
||||
setCliVerificationStatus('error');
|
||||
setCliVerificationError('Verification API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass "cli" to verify CLI authentication only (ignores any API key)
|
||||
const result = await api.setup.verifyClaudeAuth("cli");
|
||||
const result = await api.setup.verifyClaudeAuth('cli');
|
||||
|
||||
// Check for "Limit reached" error - treat as unverified
|
||||
const hasLimitReachedError =
|
||||
result.error?.toLowerCase().includes("limit reached") ||
|
||||
result.error?.toLowerCase().includes("rate limit");
|
||||
result.error?.toLowerCase().includes('limit reached') ||
|
||||
result.error?.toLowerCase().includes('rate limit');
|
||||
|
||||
if (result.authenticated && !hasLimitReachedError) {
|
||||
setCliVerificationStatus("verified");
|
||||
setCliVerificationStatus('verified');
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "cli_authenticated",
|
||||
method: 'cli_authenticated',
|
||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||
});
|
||||
toast.success("Claude CLI authentication verified!");
|
||||
toast.success('Claude CLI authentication verified!');
|
||||
} else {
|
||||
setCliVerificationStatus("error");
|
||||
setCliVerificationStatus('error');
|
||||
setCliVerificationError(
|
||||
hasLimitReachedError
|
||||
? "Rate limit reached. Please try again later."
|
||||
: result.error || "Authentication failed"
|
||||
? 'Rate limit reached. Please try again later.'
|
||||
: result.error || 'Authentication failed'
|
||||
);
|
||||
setClaudeAuthStatus({
|
||||
authenticated: false,
|
||||
method: "none",
|
||||
method: 'none',
|
||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Verification failed";
|
||||
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||
// Also check for limit reached in caught errors
|
||||
const isLimitError =
|
||||
errorMessage.toLowerCase().includes("limit reached") ||
|
||||
errorMessage.toLowerCase().includes("rate limit");
|
||||
setCliVerificationStatus("error");
|
||||
errorMessage.toLowerCase().includes('limit reached') ||
|
||||
errorMessage.toLowerCase().includes('rate limit');
|
||||
setCliVerificationStatus('error');
|
||||
setCliVerificationError(
|
||||
isLimitError
|
||||
? "Rate limit reached. Please try again later."
|
||||
: errorMessage
|
||||
isLimitError ? 'Rate limit reached. Please try again later.' : errorMessage
|
||||
);
|
||||
}
|
||||
}, [claudeAuthStatus, setClaudeAuthStatus]);
|
||||
|
||||
// Verify API Key authentication (uses API key only)
|
||||
const verifyApiKeyAuth = useCallback(async () => {
|
||||
setApiKeyVerificationStatus("verifying");
|
||||
setApiKeyVerificationStatus('verifying');
|
||||
setApiKeyVerificationError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.verifyClaudeAuth) {
|
||||
setApiKeyVerificationStatus("error");
|
||||
setApiKeyVerificationError("Verification API not available");
|
||||
setApiKeyVerificationStatus('error');
|
||||
setApiKeyVerificationError('Verification API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass "api_key" to verify API key authentication only
|
||||
const result = await api.setup.verifyClaudeAuth("api_key");
|
||||
const result = await api.setup.verifyClaudeAuth('api_key');
|
||||
|
||||
if (result.authenticated) {
|
||||
setApiKeyVerificationStatus("verified");
|
||||
setApiKeyVerificationStatus('verified');
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: "api_key",
|
||||
method: 'api_key',
|
||||
hasCredentialsFile: false,
|
||||
apiKeyValid: true,
|
||||
});
|
||||
toast.success("API key authentication verified!");
|
||||
toast.success('API key authentication verified!');
|
||||
} else {
|
||||
setApiKeyVerificationStatus("error");
|
||||
setApiKeyVerificationError(result.error || "Authentication failed");
|
||||
setApiKeyVerificationStatus('error');
|
||||
setApiKeyVerificationError(result.error || 'Authentication failed');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Verification failed";
|
||||
setApiKeyVerificationStatus("error");
|
||||
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||
setApiKeyVerificationStatus('error');
|
||||
setApiKeyVerificationError(errorMessage);
|
||||
}
|
||||
}, [setClaudeAuthStatus]);
|
||||
@@ -238,29 +212,28 @@ export function ClaudeSetupStep({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.deleteApiKey) {
|
||||
toast.error("Delete API not available");
|
||||
toast.error('Delete API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.setup.deleteApiKey("anthropic");
|
||||
const result = await api.setup.deleteApiKey('anthropic');
|
||||
if (result.success) {
|
||||
// Clear local state
|
||||
setApiKey("");
|
||||
setApiKeys({ ...apiKeys, anthropic: "" });
|
||||
setApiKeyVerificationStatus("idle");
|
||||
setApiKey('');
|
||||
setApiKeys({ ...apiKeys, anthropic: '' });
|
||||
setApiKeyVerificationStatus('idle');
|
||||
setApiKeyVerificationError(null);
|
||||
setClaudeAuthStatus({
|
||||
authenticated: false,
|
||||
method: "none",
|
||||
method: 'none',
|
||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||
});
|
||||
toast.success("API key deleted successfully");
|
||||
toast.success('API key deleted successfully');
|
||||
} else {
|
||||
toast.error(result.error || "Failed to delete API key");
|
||||
toast.error(result.error || 'Failed to delete API key');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to delete API key";
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeletingApiKey(false);
|
||||
@@ -282,30 +255,30 @@ export function ClaudeSetupStep({
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success("Command copied to clipboard");
|
||||
toast.success('Command copied to clipboard');
|
||||
};
|
||||
|
||||
// User is ready if either method is verified
|
||||
const hasApiKey =
|
||||
!!apiKeys.anthropic ||
|
||||
claudeAuthStatus?.method === "api_key" ||
|
||||
claudeAuthStatus?.method === "api_key_env";
|
||||
const isCliVerified = cliVerificationStatus === "verified";
|
||||
const isApiKeyVerified = apiKeyVerificationStatus === "verified";
|
||||
claudeAuthStatus?.method === 'api_key' ||
|
||||
claudeAuthStatus?.method === 'api_key_env';
|
||||
const isCliVerified = cliVerificationStatus === 'verified';
|
||||
const isApiKeyVerified = apiKeyVerificationStatus === 'verified';
|
||||
const isReady = isCliVerified || isApiKeyVerified;
|
||||
|
||||
const getAuthMethodLabel = () => {
|
||||
if (isApiKeyVerified) return "API Key";
|
||||
if (isCliVerified) return "Claude CLI";
|
||||
if (isApiKeyVerified) return 'API Key';
|
||||
if (isCliVerified) return 'Claude CLI';
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper to get status badge for CLI
|
||||
const getCliStatusBadge = () => {
|
||||
if (cliVerificationStatus === "verified") {
|
||||
if (cliVerificationStatus === 'verified') {
|
||||
return <StatusBadge status="authenticated" label="Verified" />;
|
||||
}
|
||||
if (cliVerificationStatus === "error") {
|
||||
if (cliVerificationStatus === 'error') {
|
||||
return <StatusBadge status="error" label="Error" />;
|
||||
}
|
||||
if (isChecking) {
|
||||
@@ -320,10 +293,10 @@ export function ClaudeSetupStep({
|
||||
|
||||
// Helper to get status badge for API Key
|
||||
const getApiKeyStatusBadge = () => {
|
||||
if (apiKeyVerificationStatus === "verified") {
|
||||
if (apiKeyVerificationStatus === 'verified') {
|
||||
return <StatusBadge status="authenticated" label="Verified" />;
|
||||
}
|
||||
if (apiKeyVerificationStatus === "error") {
|
||||
if (apiKeyVerificationStatus === 'error') {
|
||||
return <StatusBadge status="error" label="Error" />;
|
||||
}
|
||||
if (hasApiKey) {
|
||||
@@ -339,9 +312,7 @@ export function ClaudeSetupStep({
|
||||
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-8 h-8 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
API Key Setup
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">API Key Setup</h2>
|
||||
<p className="text-muted-foreground">Configure for code generation</p>
|
||||
</div>
|
||||
|
||||
@@ -353,15 +324,8 @@ export function ClaudeSetupStep({
|
||||
<Info className="w-5 h-5" />
|
||||
Authentication Methods
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
@@ -377,16 +341,14 @@ export function ClaudeSetupStep({
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal
|
||||
className={`w-5 h-5 ${
|
||||
cliVerificationStatus === "verified"
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground"
|
||||
cliVerificationStatus === 'verified'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">Claude CLI</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use Claude Code subscription
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Use Claude Code subscription</p>
|
||||
</div>
|
||||
</div>
|
||||
{getCliStatusBadge()}
|
||||
@@ -398,15 +360,11 @@ export function ClaudeSetupStep({
|
||||
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="font-medium text-foreground">
|
||||
Install Claude CLI
|
||||
</p>
|
||||
<p className="font-medium text-foreground">Install Claude CLI</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
macOS / Linux
|
||||
</Label>
|
||||
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
@@ -415,9 +373,7 @@ export function ClaudeSetupStep({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(
|
||||
"curl -fsSL https://claude.ai/install.sh | bash"
|
||||
)
|
||||
copyCommand('curl -fsSL https://claude.ai/install.sh | bash')
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
@@ -426,9 +382,7 @@ export function ClaudeSetupStep({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Windows
|
||||
</Label>
|
||||
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
irm https://claude.ai/install.ps1 | iex
|
||||
@@ -436,20 +390,14 @@ export function ClaudeSetupStep({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(
|
||||
"irm https://claude.ai/install.ps1 | iex"
|
||||
)
|
||||
}
|
||||
onClick={() => copyCommand('irm https://claude.ai/install.ps1 | iex')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && (
|
||||
<TerminalOutput lines={installProgress.output} />
|
||||
)}
|
||||
{isInstalling && <TerminalOutput lines={installProgress.output} />}
|
||||
|
||||
<Button
|
||||
onClick={install}
|
||||
@@ -480,27 +428,21 @@ export function ClaudeSetupStep({
|
||||
)}
|
||||
|
||||
{/* CLI Verification Status */}
|
||||
{cliVerificationStatus === "verifying" && (
|
||||
{cliVerificationStatus === 'verifying' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Verifying CLI authentication...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running a test query
|
||||
</p>
|
||||
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
|
||||
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === "verified" && (
|
||||
{cliVerificationStatus === 'verified' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
CLI Authentication verified!
|
||||
</p>
|
||||
<p className="font-medium text-foreground">CLI Authentication verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your Claude CLI is working correctly.
|
||||
</p>
|
||||
@@ -508,17 +450,13 @@ export function ClaudeSetupStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === "error" && cliVerificationError && (
|
||||
{cliVerificationStatus === 'error' && cliVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
Verification failed
|
||||
</p>
|
||||
<p className="text-sm text-red-400 mt-1">
|
||||
{cliVerificationError}
|
||||
</p>
|
||||
{cliVerificationError.includes("login") && (
|
||||
<p className="font-medium text-foreground">Verification failed</p>
|
||||
<p className="text-sm text-red-400 mt-1">{cliVerificationError}</p>
|
||||
{cliVerificationError.includes('login') && (
|
||||
<div className="mt-3 p-3 rounded bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Run this command in your terminal:
|
||||
@@ -530,7 +468,7 @@ export function ClaudeSetupStep({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("claude login")}
|
||||
onClick={() => copyCommand('claude login')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -542,22 +480,19 @@ export function ClaudeSetupStep({
|
||||
)}
|
||||
|
||||
{/* CLI Verify Button - Hide if CLI is verified */}
|
||||
{cliVerificationStatus !== "verified" && (
|
||||
{cliVerificationStatus !== 'verified' && (
|
||||
<Button
|
||||
onClick={verifyCliAuth}
|
||||
disabled={
|
||||
cliVerificationStatus === "verifying" ||
|
||||
!claudeCliStatus?.installed
|
||||
}
|
||||
disabled={cliVerificationStatus === 'verifying' || !claudeCliStatus?.installed}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="verify-cli-button"
|
||||
>
|
||||
{cliVerificationStatus === "verifying" ? (
|
||||
{cliVerificationStatus === 'verifying' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : cliVerificationStatus === "error" ? (
|
||||
) : cliVerificationStatus === 'error' ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Verification
|
||||
@@ -580,15 +515,13 @@ export function ClaudeSetupStep({
|
||||
<div className="flex items-center gap-3">
|
||||
<Key
|
||||
className={`w-5 h-5 ${
|
||||
apiKeyVerificationStatus === "verified"
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground"
|
||||
apiKeyVerificationStatus === 'verified'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">
|
||||
Anthropic API Key
|
||||
</p>
|
||||
<p className="font-medium text-foreground">Anthropic API Key</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pay-per-use with your own API key
|
||||
</p>
|
||||
@@ -614,7 +547,7 @@ export function ClaudeSetupStep({
|
||||
data-testid="anthropic-api-key-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Don't have an API key?{" "}
|
||||
Don't have an API key?{' '}
|
||||
<a
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
@@ -640,7 +573,7 @@ export function ClaudeSetupStep({
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save API Key"
|
||||
'Save API Key'
|
||||
)}
|
||||
</Button>
|
||||
{hasApiKey && (
|
||||
@@ -662,27 +595,21 @@ export function ClaudeSetupStep({
|
||||
</div>
|
||||
|
||||
{/* API Key Verification Status */}
|
||||
{apiKeyVerificationStatus === "verifying" && (
|
||||
{apiKeyVerificationStatus === 'verifying' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Verifying API key...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running a test query
|
||||
</p>
|
||||
<p className="font-medium text-foreground">Verifying API key...</p>
|
||||
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus === "verified" && (
|
||||
{apiKeyVerificationStatus === 'verified' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
API Key verified!
|
||||
</p>
|
||||
<p className="font-medium text-foreground">API Key verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your API key is working correctly.
|
||||
</p>
|
||||
@@ -690,37 +617,30 @@ export function ClaudeSetupStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus === "error" &&
|
||||
apiKeyVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
Verification failed
|
||||
</p>
|
||||
<p className="text-sm text-red-400 mt-1">
|
||||
{apiKeyVerificationError}
|
||||
</p>
|
||||
</div>
|
||||
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">Verification failed</p>
|
||||
<p className="text-sm text-red-400 mt-1">{apiKeyVerificationError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Verify Button - Hide if API key is verified */}
|
||||
{apiKeyVerificationStatus !== "verified" && (
|
||||
{apiKeyVerificationStatus !== 'verified' && (
|
||||
<Button
|
||||
onClick={verifyApiKeyAuth}
|
||||
disabled={
|
||||
apiKeyVerificationStatus === "verifying" || !hasApiKey
|
||||
}
|
||||
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="verify-api-key-button"
|
||||
>
|
||||
{apiKeyVerificationStatus === "verifying" ? (
|
||||
{apiKeyVerificationStatus === 'verifying' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : apiKeyVerificationStatus === "error" ? (
|
||||
) : apiKeyVerificationStatus === 'error' ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Verification
|
||||
@@ -741,20 +661,12 @@ export function ClaudeSetupStep({
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, Sparkles } from 'lucide-react';
|
||||
|
||||
interface CompleteStepProps {
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
|
||||
const { apiKeys } = useAppStore();
|
||||
|
||||
const claudeReady =
|
||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||
apiKeys.anthropic;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
|
||||
<div className="w-20 h-20 rounded-full bg-linear-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Setup Complete!
|
||||
</h2>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">Setup Complete!</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Your development environment is configured. You're ready to start
|
||||
building with AI-powered assistance.
|
||||
Your development environment is configured. You're ready to start building with
|
||||
AI-powered assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onFinish}
|
||||
data-testid="setup-finish-button"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Re-export all setup step components for easier imports
|
||||
export { WelcomeStep } from "./welcome-step";
|
||||
export { CompleteStep } from "./complete-step";
|
||||
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||
export { GitHubSetupStep } from "./github-setup-step";
|
||||
export { WelcomeStep } from './welcome-step';
|
||||
export { ThemeStep } from './theme-step';
|
||||
export { CompleteStep } from './complete-step';
|
||||
export { ClaudeSetupStep } from './claude-setup-step';
|
||||
export { GitHubSetupStep } from './github-setup-step';
|
||||
|
||||
118
apps/ui/src/components/views/setup-view/steps/theme-step.tsx
Normal file
118
apps/ui/src/components/views/setup-view/steps/theme-step.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, ArrowLeft, Check, Moon, Sun } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ThemeStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
|
||||
const { theme, setTheme, setPreviewTheme } = useAppStore();
|
||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||
|
||||
const handleThemeHover = (themeValue: string) => {
|
||||
setPreviewTheme(themeValue as typeof theme);
|
||||
};
|
||||
|
||||
const handleThemeLeave = () => {
|
||||
setPreviewTheme(null);
|
||||
};
|
||||
|
||||
const handleThemeClick = (themeValue: string) => {
|
||||
setTheme(themeValue as typeof theme);
|
||||
setPreviewTheme(null);
|
||||
};
|
||||
|
||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">Choose Your Theme</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Pick a theme that suits your style. Hover to preview, click to select.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dark/Light Tabs */}
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('dark')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium transition-all duration-200',
|
||||
activeTab === 'dark'
|
||||
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
|
||||
: 'bg-accent/50 text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
Dark Themes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('light')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium transition-all duration-200',
|
||||
activeTab === 'light'
|
||||
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
|
||||
: 'bg-accent/50 text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
Light Themes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{themesToShow.map((option) => {
|
||||
const Icon = option.Icon;
|
||||
const isSelected = theme === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
data-testid={option.testId}
|
||||
onMouseEnter={() => handleThemeHover(option.value)}
|
||||
onMouseLeave={handleThemeLeave}
|
||||
onClick={() => handleThemeClick(option.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200',
|
||||
'hover:scale-105 hover:shadow-lg',
|
||||
isSelected
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-border hover:border-brand-400 bg-card'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
<Icon className="w-6 h-6" style={{ color: option.color }} />
|
||||
<span className="text-sm font-medium text-foreground">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onNext}
|
||||
data-testid="theme-continue-button"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Terminal, ArrowRight } from "lucide-react";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
interface WelcomeStepProps {
|
||||
onNext: () => void;
|
||||
@@ -10,17 +9,14 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex items-center justify-center mx-auto">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Welcome to Automaker
|
||||
</h2>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
To get started, we'll need to verify either claude code cli is
|
||||
installed or you have Anthropic api keys
|
||||
To get started, we'll need to verify either claude code cli is installed or you have
|
||||
Anthropic api keys
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,10 +7,10 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAppStore, type ThemeMode } from "@/store/app-store";
|
||||
import { getElectronAPI, type Project } from "@/lib/electron";
|
||||
import { initializeProject } from "@/lib/project-init";
|
||||
} from '@/components/ui/dialog';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
@@ -21,19 +20,19 @@ import {
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
||||
import { NewProjectModal } from "@/components/new-project-modal";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import type { StarterTemplate } from "@/lib/templates";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { StarterTemplate } from '@/lib/templates';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
|
||||
export function WelcomeView() {
|
||||
const {
|
||||
@@ -66,24 +65,24 @@ export function WelcomeView() {
|
||||
const api = getElectronAPI();
|
||||
|
||||
if (!api.autoMode?.analyzeProject) {
|
||||
console.log("[Welcome] Auto mode API not available, skipping analysis");
|
||||
console.log('[Welcome] Auto mode API not available, skipping analysis');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
console.log("[Welcome] Starting project analysis for:", projectPath);
|
||||
console.log('[Welcome] Starting project analysis for:', projectPath);
|
||||
const result = await api.autoMode.analyzeProject(projectPath);
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Project analyzed", {
|
||||
description: "AI agent has analyzed your project structure",
|
||||
toast.success('Project analyzed', {
|
||||
description: 'AI agent has analyzed your project structure',
|
||||
});
|
||||
} else {
|
||||
console.error("[Welcome] Project analysis failed:", result.error);
|
||||
console.error('[Welcome] Project analysis failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Welcome] Failed to analyze project:", error);
|
||||
console.error('[Welcome] Failed to analyze project:', error);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
@@ -100,8 +99,8 @@ export function WelcomeView() {
|
||||
const initResult = await initializeProject(path);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error("Failed to initialize project", {
|
||||
description: initResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +112,7 @@ export function WelcomeView() {
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
|
||||
// Show initialization dialog if files were created
|
||||
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
||||
@@ -126,26 +125,23 @@ export function WelcomeView() {
|
||||
setShowInitDialog(true);
|
||||
|
||||
// Kick off agent to analyze the project and update app_spec.txt
|
||||
console.log(
|
||||
"[Welcome] Project initialized, created files:",
|
||||
initResult.createdFiles
|
||||
);
|
||||
console.log("[Welcome] Kicking off project analysis agent...");
|
||||
console.log('[Welcome] Project initialized, created files:', initResult.createdFiles);
|
||||
console.log('[Welcome] Kicking off project analysis agent...');
|
||||
|
||||
// Start analysis in background (don't await, let it run async)
|
||||
analyzeProject(path);
|
||||
} else {
|
||||
toast.success("Project opened", {
|
||||
toast.success('Project opened', {
|
||||
description: `Opened ${name}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to the board view
|
||||
navigate({ to: "/board" });
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
console.error("[Welcome] Failed to open project:", error);
|
||||
toast.error("Failed to open project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
console.error('[Welcome] Failed to open project:', error);
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsOpening(false);
|
||||
@@ -178,21 +174,19 @@ export function WelcomeView() {
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
||||
const name =
|
||||
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
await initializeAndOpenProject(path, name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Welcome] Failed to check workspace config:", error);
|
||||
console.error('[Welcome] Failed to check workspace config:', error);
|
||||
// Fall back to current behavior on error
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name =
|
||||
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
await initializeAndOpenProject(path, name);
|
||||
}
|
||||
}
|
||||
@@ -224,26 +218,41 @@ export function WelcomeView() {
|
||||
};
|
||||
|
||||
const handleInteractiveMode = () => {
|
||||
navigate({ to: "/interview" });
|
||||
navigate({ to: '/interview' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a blank project with just .automaker directory structure
|
||||
*/
|
||||
const handleCreateBlankProject = async (
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => {
|
||||
const handleCreateBlankProject = async (projectName: string, parentDir: string) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Validate that parent directory exists
|
||||
const parentExists = await api.exists(parentDir);
|
||||
if (!parentExists) {
|
||||
toast.error('Parent directory does not exist', {
|
||||
description: `Cannot create project in non-existent directory: ${parentDir}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify parent is actually a directory
|
||||
const parentStat = await api.stat(parentDir);
|
||||
if (parentStat && !parentStat.stats?.isDirectory) {
|
||||
toast.error('Parent path is not a directory', {
|
||||
description: `${parentDir} is not a directory`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create project directory
|
||||
const mkdirResult = await api.mkdir(projectPath);
|
||||
if (!mkdirResult.success) {
|
||||
toast.error("Failed to create project directory", {
|
||||
description: mkdirResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to create project directory', {
|
||||
description: mkdirResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -252,8 +261,8 @@ export function WelcomeView() {
|
||||
const initResult = await initializeProject(projectPath);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error("Failed to initialize project", {
|
||||
description: initResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -295,7 +304,7 @@ export function WelcomeView() {
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success("Project created", {
|
||||
toast.success('Project created', {
|
||||
description: `Created ${projectName} with .automaker directory`,
|
||||
});
|
||||
|
||||
@@ -308,9 +317,9 @@ export function WelcomeView() {
|
||||
});
|
||||
setShowInitDialog(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project:", error);
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
console.error('Failed to create project:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -338,8 +347,8 @@ export function WelcomeView() {
|
||||
);
|
||||
|
||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||
toast.error("Failed to clone template", {
|
||||
description: cloneResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to clone template', {
|
||||
description: cloneResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -350,8 +359,8 @@ export function WelcomeView() {
|
||||
const initResult = await initializeProject(projectPath);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error("Failed to initialize project", {
|
||||
description: initResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -369,15 +378,11 @@ export function WelcomeView() {
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
${template.techStack
|
||||
.map((tech) => `<technology>${tech}</technology>`)
|
||||
.join("\n ")}
|
||||
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
${template.features
|
||||
.map((feature) => `<capability>${feature}</capability>`)
|
||||
.join("\n ")}
|
||||
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
@@ -397,7 +402,7 @@ export function WelcomeView() {
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success("Project created from template", {
|
||||
toast.success('Project created from template', {
|
||||
description: `Created ${projectName} from ${template.name}`,
|
||||
});
|
||||
|
||||
@@ -413,9 +418,9 @@ export function WelcomeView() {
|
||||
// Kick off project analysis
|
||||
analyzeProject(projectPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project from template:", error);
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
console.error('Failed to create project from template:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -436,15 +441,11 @@ export function WelcomeView() {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Clone the repository
|
||||
const cloneResult = await httpClient.templates.clone(
|
||||
repoUrl,
|
||||
projectName,
|
||||
parentDir
|
||||
);
|
||||
const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir);
|
||||
|
||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||
toast.error("Failed to clone repository", {
|
||||
description: cloneResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to clone repository', {
|
||||
description: cloneResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -455,8 +456,8 @@ export function WelcomeView() {
|
||||
const initResult = await initializeProject(projectPath);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error("Failed to initialize project", {
|
||||
description: initResult.error || "Unknown error occurred",
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -498,7 +499,7 @@ export function WelcomeView() {
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success("Project created from repository", {
|
||||
toast.success('Project created from repository', {
|
||||
description: `Created ${projectName} from ${repoUrl}`,
|
||||
});
|
||||
|
||||
@@ -514,9 +515,9 @@ export function WelcomeView() {
|
||||
// Kick off project analysis
|
||||
analyzeProject(projectPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project from custom URL:", error);
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
console.error('Failed to create project from custom URL:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -537,7 +538,7 @@ export function WelcomeView() {
|
||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
|
||||
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
|
||||
<img src="/logo.png" alt="Automaker Logo" className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -562,26 +563,23 @@ export function WelcomeView() {
|
||||
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
|
||||
data-testid="new-project-card"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative p-6 h-full flex flex-col">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
|
||||
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
|
||||
<Plus className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
||||
New Project
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">New Project</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Create a new project from scratch with AI-powered
|
||||
development
|
||||
Create a new project from scratch with AI-powered development
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="w-full mt-5 bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
|
||||
className="w-full mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
|
||||
data-testid="create-new-project"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
@@ -590,10 +588,7 @@ export function WelcomeView() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleNewProject}
|
||||
data-testid="quick-setup-option"
|
||||
>
|
||||
<DropdownMenuItem onClick={handleNewProject} data-testid="quick-setup-option">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Quick Setup
|
||||
</DropdownMenuItem>
|
||||
@@ -615,16 +610,14 @@ export function WelcomeView() {
|
||||
onClick={handleOpenProject}
|
||||
data-testid="open-project-card"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative p-6 h-full flex flex-col">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
|
||||
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
||||
Open Project
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">Open Project</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Open an existing project folder to continue working
|
||||
</p>
|
||||
@@ -649,9 +642,7 @@ export function WelcomeView() {
|
||||
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Recent Projects
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">Recent Projects</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{recentProjects.map((project, index) => (
|
||||
@@ -662,7 +653,7 @@ export function WelcomeView() {
|
||||
data-testid={`recent-project-${project.id}`}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
|
||||
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
|
||||
@@ -677,9 +668,7 @@ export function WelcomeView() {
|
||||
</p>
|
||||
{project.lastOpened && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{new Date(
|
||||
project.lastOpened
|
||||
).toLocaleDateString()}
|
||||
{new Date(project.lastOpened).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -697,9 +686,7 @@ export function WelcomeView() {
|
||||
<div className="w-20 h-20 rounded-2xl bg-muted/50 border border-border flex items-center justify-center mb-5">
|
||||
<Sparkles className="w-10 h-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
No projects yet
|
||||
</h3>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">No projects yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md leading-relaxed">
|
||||
Get started by creating a new project or opening an existing one
|
||||
</p>
|
||||
@@ -729,9 +716,7 @@ export function WelcomeView() {
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
{initStatus?.isNewProject
|
||||
? "Project Initialized"
|
||||
: "Project Updated"}
|
||||
{initStatus?.isNewProject ? 'Project Initialized' : 'Project Updated'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground mt-1">
|
||||
{initStatus?.isNewProject
|
||||
@@ -741,9 +726,7 @@ export function WelcomeView() {
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
Created files:
|
||||
</p>
|
||||
<p className="text-sm text-foreground font-medium">Created files:</p>
|
||||
<ul className="space-y-2">
|
||||
{initStatus?.createdFiles.map((file) => (
|
||||
<li
|
||||
@@ -770,12 +753,12 @@ export function WelcomeView() {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<span className="text-brand-500 font-medium">Tip:</span> Edit the{" "}
|
||||
<span className="text-brand-500 font-medium">Tip:</span> Edit the{' '}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
|
||||
app_spec.txt
|
||||
</code>{" "}
|
||||
file to describe your project. The AI agent will use this to
|
||||
understand your project structure.
|
||||
</code>{' '}
|
||||
file to describe your project. The AI agent will use this to understand your
|
||||
project structure.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -784,7 +767,7 @@ export function WelcomeView() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setShowInitDialog(false)}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
|
||||
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
|
||||
data-testid="close-init-dialog"
|
||||
>
|
||||
Get Started
|
||||
@@ -808,9 +791,7 @@ export function WelcomeView() {
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
|
||||
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
||||
<p className="text-foreground font-medium">
|
||||
Initializing project...
|
||||
</p>
|
||||
<p className="text-foreground font-medium">Initializing project...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState, type ReactNode, type ElementType } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
PlayCircle,
|
||||
Bot,
|
||||
LayoutGrid,
|
||||
FileText,
|
||||
Terminal,
|
||||
Palette,
|
||||
Keyboard,
|
||||
@@ -23,13 +21,13 @@ import {
|
||||
TestTube,
|
||||
Brain,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WikiSection {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
content: React.ReactNode;
|
||||
icon: ElementType;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
@@ -52,9 +50,7 @@ function CollapsibleSection({
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-brand-500/10 text-brand-500">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-foreground">
|
||||
{section.title}
|
||||
</span>
|
||||
<span className="flex-1 font-medium text-foreground">{section.title}</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
@@ -90,7 +86,7 @@ function CodeBlock({ children, title }: { children: string; title?: string }) {
|
||||
function FeatureList({
|
||||
items,
|
||||
}: {
|
||||
items: { icon: React.ElementType; title: string; description: string }[];
|
||||
items: { icon: ElementType; title: string; description: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 mt-3">
|
||||
@@ -105,12 +101,8 @@ function FeatureList({
|
||||
<ItemIcon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="font-medium text-foreground text-sm">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -120,9 +112,7 @@ function FeatureList({
|
||||
}
|
||||
|
||||
export function WikiView() {
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(
|
||||
new Set(["overview"])
|
||||
);
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['overview']));
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
@@ -146,66 +136,66 @@ export function WikiView() {
|
||||
|
||||
const sections: WikiSection[] = [
|
||||
{
|
||||
id: "overview",
|
||||
title: "Project Overview",
|
||||
id: 'overview',
|
||||
title: 'Project Overview',
|
||||
icon: Rocket,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
<strong className="text-foreground">Automaker</strong> is an
|
||||
autonomous AI development studio that helps developers build
|
||||
software faster using AI agents.
|
||||
<strong className="text-foreground">Automaker</strong> is an autonomous AI development
|
||||
studio that helps developers build software faster using AI agents.
|
||||
</p>
|
||||
<p>
|
||||
At its core, Automaker provides a visual Kanban board to manage
|
||||
features. When you're ready, AI agents automatically implement those
|
||||
features in your codebase, complete with git worktree isolation for
|
||||
safe parallel development.
|
||||
At its core, Automaker provides a visual Kanban board to manage features. When you're
|
||||
ready, AI agents automatically implement those features in your codebase, complete with
|
||||
git worktree isolation for safe parallel development.
|
||||
</p>
|
||||
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
|
||||
<p className="text-brand-400 text-sm">
|
||||
Think of it as having a team of AI developers that can work on
|
||||
multiple features simultaneously while you focus on the bigger
|
||||
picture.
|
||||
Think of it as having a team of AI developers that can work on multiple features
|
||||
simultaneously while you focus on the bigger picture.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "architecture",
|
||||
title: "Architecture",
|
||||
id: 'architecture',
|
||||
title: 'Architecture',
|
||||
icon: Layers,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>Automaker is built as a monorepo with two main applications:</p>
|
||||
<p>Automaker is built as a monorepo with two main applications and shared libraries:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-2">
|
||||
<li>
|
||||
<strong className="text-foreground">apps/ui</strong> - Next.js +
|
||||
<strong className="text-foreground">apps/ui</strong> - React + TanStack Router +
|
||||
Electron frontend for the desktop application
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">apps/server</strong> - Express
|
||||
backend handling API requests and agent orchestration
|
||||
<strong className="text-foreground">apps/server</strong> - Express backend handling
|
||||
API requests and agent orchestration
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">libs/</strong> - Shared packages for types,
|
||||
utilities, and common logic used across apps
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="font-medium text-foreground">Key Technologies:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Electron wraps Next.js for cross-platform desktop support</li>
|
||||
<li>
|
||||
Real-time communication via WebSocket for live agent updates
|
||||
</li>
|
||||
<li>Electron + React + TanStack Router for cross-platform desktop support</li>
|
||||
<li>Real-time communication via WebSocket for live agent updates</li>
|
||||
<li>State management with Zustand for reactive UI updates</li>
|
||||
<li>Claude Agent SDK for AI capabilities</li>
|
||||
<li>Shared monorepo packages (@automaker/*) for code reuse</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
title: "Key Features",
|
||||
id: 'features',
|
||||
title: 'Key Features',
|
||||
icon: Sparkles,
|
||||
content: (
|
||||
<div>
|
||||
@@ -213,73 +203,69 @@ export function WikiView() {
|
||||
items={[
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: "Kanban Board",
|
||||
title: 'Kanban Board',
|
||||
description:
|
||||
"4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.",
|
||||
'4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.',
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "AI Agent Integration",
|
||||
title: 'AI Agent Integration',
|
||||
description:
|
||||
"Powered by Claude via the Agent SDK with full file, bash, and git access.",
|
||||
'Powered by Claude via the Agent SDK with full file, bash, and git access.',
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
title: "Multi-Model Support",
|
||||
title: 'Multi-Model Support',
|
||||
description:
|
||||
"Claude Haiku/Sonnet/Opus models. Choose the right model for each task.",
|
||||
'Claude Haiku/Sonnet/Opus models. Choose the right model for each task.',
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "Extended Thinking",
|
||||
title: 'Extended Thinking',
|
||||
description:
|
||||
"Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.",
|
||||
'Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Real-time Streaming",
|
||||
description:
|
||||
"Watch AI agents work in real-time with live output streaming.",
|
||||
title: 'Real-time Streaming',
|
||||
description: 'Watch AI agents work in real-time with live output streaming.',
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: "Git Worktree Isolation",
|
||||
title: 'Git Worktree Isolation',
|
||||
description:
|
||||
"Each feature runs in its own git worktree for safe parallel development.",
|
||||
'Each feature runs in its own git worktree for safe parallel development.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "AI Profiles",
|
||||
title: 'AI Profiles',
|
||||
description:
|
||||
"Pre-configured model + thinking level combinations for different task types.",
|
||||
'Pre-configured model + thinking level combinations for different task types.',
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: "Integrated Terminal",
|
||||
description:
|
||||
"Built-in terminal with tab support and split panes.",
|
||||
title: 'Integrated Terminal',
|
||||
description: 'Built-in terminal with tab support and split panes.',
|
||||
},
|
||||
{
|
||||
icon: Keyboard,
|
||||
title: "Keyboard Shortcuts",
|
||||
description: "Fully customizable shortcuts for power users.",
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: 'Fully customizable shortcuts for power users.',
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: "14 Themes",
|
||||
description:
|
||||
"From light to dark, retro to synthwave - pick your style.",
|
||||
title: '14 Themes',
|
||||
description: 'From light to dark, retro to synthwave - pick your style.',
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: "Image Support",
|
||||
description: "Attach images to features for visual context.",
|
||||
title: 'Image Support',
|
||||
description: 'Attach images to features for visual context.',
|
||||
},
|
||||
{
|
||||
icon: TestTube,
|
||||
title: "Test Integration",
|
||||
description:
|
||||
"Automatic test running and TDD support for quality assurance.",
|
||||
title: 'Test Integration',
|
||||
description: 'Automatic test running and TDD support for quality assurance.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -287,26 +273,23 @@ export function WikiView() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "data-flow",
|
||||
title: "How It Works (Data Flow)",
|
||||
id: 'data-flow',
|
||||
title: 'How It Works (Data Flow)',
|
||||
icon: GitBranch,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Here's what happens when you use Automaker to implement a feature:
|
||||
</p>
|
||||
<p>Here's what happens when you use Automaker to implement a feature:</p>
|
||||
<ol className="list-decimal list-inside space-y-3 ml-2 mt-4">
|
||||
<li className="text-foreground">
|
||||
<strong>Create Feature</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Add a new feature card to the Kanban board with description and
|
||||
steps
|
||||
Add a new feature card to the Kanban board with description and steps
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Feature Saved</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Feature saved to{" "}
|
||||
Feature saved to{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded text-xs">
|
||||
.automaker/features/{id}/feature.json
|
||||
</code>
|
||||
@@ -315,15 +298,13 @@ export function WikiView() {
|
||||
<li className="text-foreground">
|
||||
<strong>Start Work</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Drag to "In Progress" or enable auto mode to start
|
||||
implementation
|
||||
Drag to "In Progress" or enable auto mode to start implementation
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Git Worktree Created</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Backend AutoModeService creates isolated git worktree (if
|
||||
enabled)
|
||||
Backend AutoModeService creates isolated git worktree (if enabled)
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
@@ -355,38 +336,64 @@ export function WikiView() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "structure",
|
||||
title: "Project Structure",
|
||||
id: 'structure',
|
||||
title: 'Project Structure',
|
||||
icon: FolderTree,
|
||||
content: (
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
The Automaker codebase is organized as follows:
|
||||
</p>
|
||||
<p className="mb-3">The Automaker codebase is organized as follows:</p>
|
||||
<CodeBlock title="Directory Structure">
|
||||
{`/automaker/
|
||||
├── apps/
|
||||
│ ├── app/ # Frontend (Next.js + Electron)
|
||||
│ │ ├── electron/ # Electron main process
|
||||
│ │ └── src/
|
||||
│ │ ├── app/ # Next.js App Router pages
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── store/ # Zustand state management
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ └── lib/ # Utilities and helpers
|
||||
│ └── server/ # Backend (Express)
|
||||
│ └── src/
|
||||
│ ├── routes/ # API endpoints
|
||||
│ └── services/ # Business logic (AutoModeService, etc.)
|
||||
├── docs/ # Documentation
|
||||
└── package.json # Workspace root`}
|
||||
{`automaker/
|
||||
├─ apps/
|
||||
│ ├─ ui/ Frontend (React + Electron)
|
||||
│ │ └─ src/
|
||||
│ │ ├─ routes/ TanStack Router pages
|
||||
│ │ ├─ components/
|
||||
│ │ │ ├─ layout/ Layout components (sidebar, etc.)
|
||||
│ │ │ ├─ views/ View components (board, agent, etc.)
|
||||
│ │ │ ├─ dialogs/ Dialog components
|
||||
│ │ │ └─ ui/ shadcn/ui components
|
||||
│ │ ├─ store/ Zustand state management
|
||||
│ │ ├─ hooks/ Custom React hooks
|
||||
│ │ ├─ lib/ Utilities and helpers
|
||||
│ │ ├─ config/ App configuration files
|
||||
│ │ ├─ contexts/ React context providers
|
||||
│ │ ├─ styles/ CSS styles and theme definitions
|
||||
│ │ ├─ types/ TypeScript type definitions
|
||||
│ │ ├─ utils/ Utility functions
|
||||
│ │ ├─ main.ts Electron main process entry
|
||||
│ │ ├─ preload.ts Electron preload script
|
||||
│ │ └─ renderer.tsx React renderer entry
|
||||
│ │
|
||||
│ └─ server/ Backend (Express)
|
||||
│ └─ src/
|
||||
│ ├─ routes/ API endpoints
|
||||
│ ├─ services/ Business logic (AutoModeService, etc.)
|
||||
│ ├─ lib/ Library utilities
|
||||
│ ├─ middleware/ Express middleware
|
||||
│ ├─ providers/ AI provider implementations
|
||||
│ ├─ types/ TypeScript type definitions
|
||||
│ └─ index.ts Server entry point
|
||||
│
|
||||
├─ libs/ Shared packages (monorepo)
|
||||
│ ├─ types/ TypeScript type definitions
|
||||
│ ├─ utils/ Common utilities (logging, errors)
|
||||
│ ├─ prompts/ AI prompt templates
|
||||
│ ├─ platform/ Platform & path utilities
|
||||
│ ├─ model-resolver/ Claude model resolution
|
||||
│ ├─ dependency-resolver/ Feature dependency ordering
|
||||
│ └─ git-utils/ Git operations & parsing
|
||||
│
|
||||
├─ docs/ Documentation
|
||||
└─ package.json Workspace root
|
||||
`}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "components",
|
||||
title: "Key Components",
|
||||
id: 'components',
|
||||
title: 'Key Components',
|
||||
icon: Component,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
@@ -394,33 +401,36 @@ export function WikiView() {
|
||||
<div className="grid gap-2 mt-4">
|
||||
{[
|
||||
{
|
||||
file: "sidebar.tsx",
|
||||
desc: "Main navigation with project picker and view switching",
|
||||
file: 'layout/sidebar.tsx',
|
||||
desc: 'Main navigation with project picker and view switching',
|
||||
},
|
||||
{
|
||||
file: "board-view.tsx",
|
||||
desc: "Kanban board with drag-and-drop cards",
|
||||
file: 'views/board-view.tsx',
|
||||
desc: 'Kanban board with drag-and-drop cards',
|
||||
},
|
||||
{
|
||||
file: "agent-view.tsx",
|
||||
desc: "AI chat interface for conversational development",
|
||||
},
|
||||
{ file: "spec-view.tsx", desc: "Project specification editor" },
|
||||
{
|
||||
file: "context-view.tsx",
|
||||
desc: "Context file manager for AI context",
|
||||
file: 'views/agent-view.tsx',
|
||||
desc: 'AI chat interface for conversational development',
|
||||
},
|
||||
{
|
||||
file: "terminal-view.tsx",
|
||||
desc: "Integrated terminal with splits and tabs",
|
||||
file: 'views/spec-view/',
|
||||
desc: 'Project specification editor with AI generation',
|
||||
},
|
||||
{
|
||||
file: "profiles-view.tsx",
|
||||
desc: "AI profile management (model + thinking presets)",
|
||||
file: 'views/context-view.tsx',
|
||||
desc: 'Context file manager for AI context',
|
||||
},
|
||||
{
|
||||
file: "app-store.ts",
|
||||
desc: "Central Zustand state management",
|
||||
file: 'views/terminal-view/',
|
||||
desc: 'Integrated terminal with splits and tabs',
|
||||
},
|
||||
{
|
||||
file: 'views/profiles-view.tsx',
|
||||
desc: 'AI profile management (model + thinking presets)',
|
||||
},
|
||||
{
|
||||
file: 'store/app-store.ts',
|
||||
desc: 'Central Zustand state management',
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
@@ -430,9 +440,7 @@ export function WikiView() {
|
||||
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">
|
||||
{item.file}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.desc}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -440,31 +448,28 @@ export function WikiView() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "configuration",
|
||||
title: "Configuration",
|
||||
id: 'configuration',
|
||||
title: 'Configuration',
|
||||
icon: Settings,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Automaker stores project configuration in the{" "}
|
||||
<code className="px-1 py-0.5 bg-muted rounded text-xs">
|
||||
.automaker/
|
||||
</code>{" "}
|
||||
directory:
|
||||
Automaker stores project configuration in the{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/</code> directory:
|
||||
</p>
|
||||
<div className="grid gap-2 mt-4">
|
||||
{[
|
||||
{
|
||||
file: "app_spec.txt",
|
||||
desc: "Project specification describing your app for AI context",
|
||||
file: 'app_spec.txt',
|
||||
desc: 'Project specification describing your app for AI context',
|
||||
},
|
||||
{
|
||||
file: "context/",
|
||||
desc: "Additional context files (docs, examples) for AI",
|
||||
file: 'context/',
|
||||
desc: 'Additional context files (docs, examples) for AI',
|
||||
},
|
||||
{
|
||||
file: "features/",
|
||||
desc: "Feature definitions with descriptions and steps",
|
||||
file: 'features/',
|
||||
desc: 'Feature definitions with descriptions and steps',
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
@@ -474,16 +479,12 @@ export function WikiView() {
|
||||
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">
|
||||
{item.file}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.desc}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-sm text-foreground font-medium mb-2">
|
||||
Tip: App Spec Best Practices
|
||||
</p>
|
||||
<p className="text-sm text-foreground font-medium mb-2">Tip: App Spec Best Practices</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
|
||||
<li>Include your tech stack and key dependencies</li>
|
||||
<li>Describe the project structure and conventions</li>
|
||||
@@ -495,8 +496,8 @@ export function WikiView() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "getting-started",
|
||||
title: "Getting Started",
|
||||
id: 'getting-started',
|
||||
title: 'Getting Started',
|
||||
icon: PlayCircle,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
@@ -505,43 +506,38 @@ export function WikiView() {
|
||||
<li className="text-foreground">
|
||||
<strong>Create or Open a Project</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Use the sidebar to create a new project or open an existing
|
||||
folder
|
||||
Use the sidebar to create a new project or open an existing folder
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Write an App Spec</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Go to Spec Editor and describe your project. This helps AI
|
||||
understand your codebase.
|
||||
Go to Spec Editor and describe your project. This helps AI understand your codebase.
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Add Context (Optional)</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Add relevant documentation or examples to the Context view for
|
||||
better AI results
|
||||
Add relevant documentation or examples to the Context view for better AI results
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Create Features</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Add feature cards to your Kanban board with clear descriptions
|
||||
and implementation steps
|
||||
Add feature cards to your Kanban board with clear descriptions and implementation
|
||||
steps
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Configure AI Profile</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Choose an AI profile or customize model/thinking settings per
|
||||
feature
|
||||
Choose an AI profile or customize model/thinking settings per feature
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Start Implementation</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">
|
||||
Drag features to "In Progress" or enable auto mode to let AI
|
||||
work
|
||||
Drag features to "In Progress" or enable auto mode to let AI work
|
||||
</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
@@ -555,16 +551,12 @@ export function WikiView() {
|
||||
<p className="text-brand-400 text-sm font-medium mb-2">Pro Tips:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs text-brand-400/80">
|
||||
<li>
|
||||
Use keyboard shortcuts for faster navigation (press{" "}
|
||||
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code>{" "}
|
||||
to see all)
|
||||
Use keyboard shortcuts for faster navigation (press{' '}
|
||||
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)
|
||||
</li>
|
||||
<li>Enable git worktree isolation for parallel feature development</li>
|
||||
<li>
|
||||
Enable git worktree isolation for parallel feature development
|
||||
</li>
|
||||
<li>
|
||||
Start with "Quick Edit" profile for simple tasks, use "Heavy
|
||||
Task" for complex work
|
||||
Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
|
||||
</li>
|
||||
<li>Keep your app spec up to date as your project evolves</li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user