mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Add variant prop to Spinner component to support different color contexts: - 'primary' (default): Uses text-primary for standard backgrounds - 'foreground': Uses text-primary-foreground for primary backgrounds - 'muted': Uses text-muted-foreground for subtle contexts Updated components where spinners were invisible against primary backgrounds: - TaskProgressPanel: Active task indicators now visible - Button: Auto-detects spinner variant based on button style - Various dialogs and setup views using buttons with loaders Fixes #670 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
// @ts-nocheck
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
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, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import { cn, generateUUID } 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';
|
|
|
|
const logger = createLogger('InterviewView');
|
|
|
|
interface InterviewMessage {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
interface InterviewState {
|
|
projectName: string;
|
|
projectDescription: string;
|
|
techStack: string[];
|
|
features: string[];
|
|
additionalNotes: string;
|
|
}
|
|
|
|
// 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: '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: '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 { openFileBrowser } = useFileBrowser();
|
|
const navigate = useNavigate();
|
|
const [input, setInput] = useState('');
|
|
const [messages, setMessages] = useState<InterviewMessage[]>([]);
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
const [interviewData, setInterviewData] = useState<InterviewState>({
|
|
projectName: '',
|
|
projectDescription: '',
|
|
techStack: [],
|
|
features: [],
|
|
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 [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) {
|
|
logger.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',
|
|
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(),
|
|
};
|
|
setMessages([welcomeMessage]);
|
|
}
|
|
}, [messages.length]);
|
|
|
|
// Auto-scroll to bottom when messages change
|
|
useEffect(() => {
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
if (messagesContainerRef.current) {
|
|
// Use a small delay to ensure DOM is updated
|
|
timeoutId = setTimeout(() => {
|
|
if (messagesContainerRef.current) {
|
|
messagesContainerRef.current.scrollTo({
|
|
top: messagesContainerRef.current.scrollHeight,
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
}, 100);
|
|
}
|
|
return () => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, [messages]);
|
|
|
|
// Auto-focus input
|
|
useEffect(() => {
|
|
if (inputRef.current && !isComplete) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [currentQuestionIndex, isComplete]);
|
|
|
|
const handleSend = useCallback(() => {
|
|
if (!input.trim() || isGenerating || isComplete) return;
|
|
|
|
const userMessage: InterviewMessage = {
|
|
id: `user-${Date.now()}`,
|
|
role: 'user',
|
|
content: input,
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
setMessages((prev) => [...prev, userMessage]);
|
|
|
|
// Update interview data based on current question
|
|
const currentQuestion = INTERVIEW_QUESTIONS[currentQuestionIndex];
|
|
if (currentQuestion) {
|
|
setInterviewData((prev) => {
|
|
const newData = { ...prev };
|
|
if (currentQuestion.field === 'techStack' || currentQuestion.field === 'features') {
|
|
// Parse comma-separated values into array
|
|
newData[currentQuestion.field] = input
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
} else {
|
|
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
|
|
}
|
|
return newData;
|
|
});
|
|
}
|
|
|
|
setInput('');
|
|
|
|
// Move to next question or complete
|
|
const nextIndex = currentQuestionIndex + 1;
|
|
|
|
setTimeout(() => {
|
|
if (nextIndex < INTERVIEW_QUESTIONS.length) {
|
|
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
|
|
const assistantMessage: InterviewMessage = {
|
|
id: `assistant-${Date.now()}`,
|
|
role: 'assistant',
|
|
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
|
|
timestamp: new Date(),
|
|
};
|
|
setMessages((prev) => [...prev, assistantMessage]);
|
|
setCurrentQuestionIndex(nextIndex);
|
|
} else {
|
|
// All questions answered - generate spec
|
|
const summaryMessage: InterviewMessage = {
|
|
id: `assistant-summary-${Date.now()}`,
|
|
role: 'assistant',
|
|
content:
|
|
'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,
|
|
techStack:
|
|
currentQuestionIndex === 1
|
|
? input
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: interviewData.techStack,
|
|
features:
|
|
currentQuestionIndex === 2
|
|
? input
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: interviewData.features,
|
|
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
|
|
});
|
|
}
|
|
}, 500);
|
|
}, [input, isGenerating, isComplete, currentQuestionIndex, interviewData]);
|
|
|
|
const generateSpec = useCallback(async (data: InterviewState) => {
|
|
setIsGenerating(true);
|
|
|
|
// Generate a draft app_spec.txt based on the interview responses
|
|
const spec = generateAppSpec(data);
|
|
|
|
// Simulate some processing time for better UX
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
|
|
setGeneratedSpec(spec);
|
|
setIsGenerating(false);
|
|
setIsComplete(true);
|
|
setShowProjectSetup(true);
|
|
|
|
const completionMessage: InterviewMessage = {
|
|
id: `assistant-complete-${Date.now()}`,
|
|
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(),
|
|
};
|
|
setMessages((prev) => [...prev, completionMessage]);
|
|
}, []);
|
|
|
|
const generateAppSpec = (data: InterviewState): string => {
|
|
const projectName = data.projectDescription
|
|
.split(' ')
|
|
.slice(0, 3)
|
|
.join('-')
|
|
.toLowerCase()
|
|
.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>
|
|
|
|
<overview>
|
|
${data.projectDescription}
|
|
</overview>
|
|
|
|
<technology_stack>
|
|
${
|
|
data.techStack.length > 0
|
|
? 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 -->'
|
|
}
|
|
</core_capabilities>
|
|
|
|
<additional_requirements>
|
|
${data.additionalNotes || 'None specified'}
|
|
</additional_requirements>
|
|
|
|
<development_guidelines>
|
|
<guideline>Write clean, production-quality code</guideline>
|
|
<guideline>Include proper error handling</guideline>
|
|
<guideline>Write comprehensive Playwright tests</guideline>
|
|
<guideline>Ensure all tests pass before marking features complete</guideline>
|
|
</development_guidelines>
|
|
</project_specification>`;
|
|
};
|
|
|
|
const handleSelectDirectory = async () => {
|
|
const selectedPath = await openFileBrowser({
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleCreateProject = async () => {
|
|
if (!projectName || !projectPath || !generatedSpec) return;
|
|
|
|
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
|
|
? '\\'
|
|
: '/'
|
|
: '/';
|
|
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',
|
|
});
|
|
setIsGenerating(false);
|
|
return;
|
|
}
|
|
|
|
// Write app_spec.txt with generated content
|
|
await api.writeFile(`${fullProjectPath}/.automaker/app_spec.txt`, generatedSpec);
|
|
|
|
// Create initial feature in the features folder
|
|
const initialFeature: Feature = {
|
|
id: generateUUID(),
|
|
category: 'Core',
|
|
description: 'Initial project setup',
|
|
status: 'backlog' as const,
|
|
skipTests: true,
|
|
};
|
|
|
|
if (!api.features) {
|
|
throw new Error('Features API not available');
|
|
}
|
|
await api.features.create(fullProjectPath, initialFeature);
|
|
|
|
const project = {
|
|
id: `project-${Date.now()}`,
|
|
name: projectName,
|
|
path: fullProjectPath,
|
|
lastOpened: new Date().toISOString(),
|
|
};
|
|
|
|
// Update app spec in store
|
|
setAppSpec(generatedSpec);
|
|
|
|
// Add and select the project
|
|
addProject(project);
|
|
setCurrentProject(project);
|
|
} catch (error) {
|
|
logger.error('Failed to create project:', error);
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const handleGoBack = () => {
|
|
navigate({ to: '/' });
|
|
};
|
|
|
|
return (
|
|
<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">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleGoBack}
|
|
className="h-8 w-8 p-0"
|
|
data-testid="interview-back-button"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
</Button>
|
|
<Sparkles className="w-5 h-5 text-primary" />
|
|
<div>
|
|
<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}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress indicator */}
|
|
<div className="flex items-center gap-2">
|
|
{INTERVIEW_QUESTIONS.map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
'w-2 h-2 rounded-full transition-colors',
|
|
index < currentQuestionIndex
|
|
? 'bg-green-500'
|
|
: index === currentQuestionIndex
|
|
? 'bg-primary'
|
|
: 'bg-zinc-700'
|
|
)}
|
|
/>
|
|
))}
|
|
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div
|
|
ref={messagesContainerRef}
|
|
className="flex-1 overflow-y-auto p-4 space-y-4"
|
|
data-testid="interview-messages"
|
|
>
|
|
{messages.map((message) => (
|
|
<div
|
|
key={message.id}
|
|
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'
|
|
)}
|
|
>
|
|
{message.role === 'assistant' ? (
|
|
<Bot className="w-4 h-4 text-primary" />
|
|
) : (
|
|
<User className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<Card
|
|
className={cn(
|
|
'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' ? (
|
|
<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={cn(
|
|
'text-xs mt-1',
|
|
message.role === 'user' ? 'text-muted-foreground' : 'text-primary/70'
|
|
)}
|
|
>
|
|
{message.timestamp.toLocaleTimeString()}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
|
|
{isGenerating && !showProjectSetup && (
|
|
<div className="flex gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
|
<Bot className="w-4 h-4 text-primary" />
|
|
</div>
|
|
<Card className="border border-primary/30 bg-card">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Spinner size="sm" />
|
|
<span className="text-sm text-primary">Generating specification...</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Project Setup Form */}
|
|
{showProjectSetup && (
|
|
<div className="mt-6">
|
|
<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" />
|
|
<h3 className="text-lg font-semibold">Create Your Project</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
|
|
Project Name
|
|
</label>
|
|
<Input
|
|
id="project-name"
|
|
placeholder="my-awesome-project"
|
|
value={projectName}
|
|
onChange={(e) => setProjectName(e.target.value)}
|
|
className="bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
|
data-testid="interview-project-name-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
|
|
Parent Directory
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="project-path"
|
|
placeholder="/path/to/projects"
|
|
value={projectPath}
|
|
onChange={(e) => setProjectPath(e.target.value)}
|
|
className="flex-1 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
|
data-testid="interview-project-path-input"
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleSelectDirectory}
|
|
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
|
|
data-testid="interview-browse-directory"
|
|
>
|
|
Browse
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview of generated spec */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-zinc-300">
|
|
Generated Specification Preview
|
|
</label>
|
|
<div
|
|
className="bg-zinc-950/50 border border-white/10 rounded-md p-3 max-h-48 overflow-y-auto"
|
|
data-testid="spec-preview"
|
|
>
|
|
<pre className="text-xs text-zinc-400 whitespace-pre-wrap font-mono">
|
|
{generatedSpec}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleCreateProject}
|
|
disabled={!projectName || !projectPath || isGenerating}
|
|
className="w-full bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
|
|
data-testid="interview-create-project"
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
Create Project
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
{!isComplete && (
|
|
<div className="border-t p-4">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
ref={inputRef}
|
|
placeholder="Type your answer..."
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
disabled={isGenerating}
|
|
data-testid="interview-input"
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || isGenerating}
|
|
data-testid="interview-send"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|