mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
refactor: move from next js to vite and tanstack router
This commit is contained in:
638
apps/ui/src/components/views/interview-view.tsx
Normal file
638
apps/ui/src/components/views/interview-view.tsx
Normal file
@@ -0,0 +1,638 @@
|
||||
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
if (selectedPath) {
|
||||
setProjectPath(selectedPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!projectName || !projectPath || !generatedSpec) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
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: crypto.randomUUID(),
|
||||
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",
|
||||
],
|
||||
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) {
|
||||
console.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-l-4 border-primary 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-l-4 border-primary bg-card">
|
||||
<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>
|
||||
</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 ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user