mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-01 23:13:36 +00:00
- Create shared `isSubmitEnter()` utility in `ui/src/lib/keyboard.ts` for IME-aware Enter key handling across all input components - Extract magic number 48 to named constant `COLLAPSED_DEBUG_PANEL_CLEARANCE` with explanatory comment (40px panel header + 8px margin) - Update 5 components to use the new utility: - AssistantChat.tsx - ExpandProjectChat.tsx - SpecCreationChat.tsx - FolderBrowser.tsx - TerminalTabs.tsx This follows up on PR #121 which added IME composition checks. The refactoring centralizes the logic for easier maintenance and documents the padding value that prevents Kanban cards from being cut off when the debug panel is collapsed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
/**
|
|
* Spec Creation Chat Component
|
|
*
|
|
* Full chat interface for interactive spec creation with Claude.
|
|
* Handles the 7-phase conversation flow for creating app specifications.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink, FileText } from 'lucide-react'
|
|
import { useSpecChat } from '../hooks/useSpecChat'
|
|
import { ChatMessage } from './ChatMessage'
|
|
import { QuestionOptions } from './QuestionOptions'
|
|
import { TypingIndicator } from './TypingIndicator'
|
|
import type { ImageAttachment } from '../lib/types'
|
|
import { isSubmitEnter } from '../lib/keyboard'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
|
|
// Image upload validation constants
|
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
|
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png']
|
|
|
|
// Sample prompt for quick testing
|
|
const SAMPLE_PROMPT = `Let's call it Simple Todo. This is a really simple web app that I can use to track my to-do items using a Kanban board. I should be able to add to-dos and then drag and drop them through the Kanban board. The different columns in the Kanban board are:
|
|
|
|
- To Do
|
|
- In Progress
|
|
- Done
|
|
|
|
The app should use a neobrutalism design.
|
|
|
|
There is no need for user authentication either. All the to-dos will be stored in local storage, so each user has access to all of their to-dos when they open their browser. So do not worry about implementing a backend with user authentication or a database. Simply store everything in local storage. As for the design, please try to avoid AI slop, so use your front-end design skills to design something beautiful and practical. As for the content of the to-dos, we should store:
|
|
|
|
- The name or the title at the very least
|
|
- Optionally, we can also set tags, due dates, and priorities which should be represented as beautiful little badges on the to-do card
|
|
|
|
Users should have the ability to easily clear out all the completed To-Dos. They should also be able to filter and search for To-Dos as well.
|
|
|
|
You choose the rest. Keep it simple. Should be 25 features.`
|
|
|
|
type InitializerStatus = 'idle' | 'starting' | 'error'
|
|
|
|
interface SpecCreationChatProps {
|
|
projectName: string
|
|
onComplete: (specPath: string, yoloMode?: boolean) => void
|
|
onCancel: () => void
|
|
onExitToProject: () => void // Exit to project without starting agent
|
|
initializerStatus?: InitializerStatus
|
|
initializerError?: string | null
|
|
onRetryInitializer?: () => void
|
|
}
|
|
|
|
export function SpecCreationChat({
|
|
projectName,
|
|
onComplete,
|
|
onCancel,
|
|
onExitToProject,
|
|
initializerStatus = 'idle',
|
|
initializerError = null,
|
|
onRetryInitializer,
|
|
}: SpecCreationChatProps) {
|
|
const [input, setInput] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [yoloEnabled, setYoloEnabled] = useState(false)
|
|
const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const {
|
|
messages,
|
|
isLoading,
|
|
isComplete,
|
|
connectionStatus,
|
|
currentQuestions,
|
|
start,
|
|
sendMessage,
|
|
sendAnswer,
|
|
disconnect,
|
|
} = useSpecChat({
|
|
projectName,
|
|
onComplete,
|
|
onError: (err) => setError(err),
|
|
})
|
|
|
|
// Start the chat session when component mounts
|
|
useEffect(() => {
|
|
start()
|
|
|
|
return () => {
|
|
disconnect()
|
|
}
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Scroll to bottom when messages change
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages, currentQuestions, isLoading])
|
|
|
|
// Focus input when not loading and no questions
|
|
useEffect(() => {
|
|
if (!isLoading && !currentQuestions && inputRef.current) {
|
|
inputRef.current.focus()
|
|
}
|
|
}, [isLoading, currentQuestions])
|
|
|
|
const handleSendMessage = () => {
|
|
const trimmed = input.trim()
|
|
// Allow sending if there's text OR attachments
|
|
if ((!trimmed && pendingAttachments.length === 0) || isLoading) return
|
|
|
|
// Detect /exit command - exit to project without sending to Claude
|
|
if (/^\s*\/exit\s*$/i.test(trimmed)) {
|
|
setInput('')
|
|
onExitToProject()
|
|
return
|
|
}
|
|
|
|
sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
|
|
setInput('')
|
|
setPendingAttachments([]) // Clear attachments after sending
|
|
// Reset textarea height after sending
|
|
if (inputRef.current) {
|
|
inputRef.current.style.height = 'auto'
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (isSubmitEnter(e)) {
|
|
e.preventDefault()
|
|
handleSendMessage()
|
|
}
|
|
}
|
|
|
|
const handleAnswerSubmit = (answers: Record<string, string | string[]>) => {
|
|
sendAnswer(answers)
|
|
}
|
|
|
|
// File handling for image attachments
|
|
const handleFileSelect = useCallback((files: FileList | null) => {
|
|
if (!files) return
|
|
|
|
Array.from(files).forEach((file) => {
|
|
// Validate file type
|
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`)
|
|
return
|
|
}
|
|
|
|
// Validate file size
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
setError(`File too large: ${file.name}. Maximum size is 5 MB.`)
|
|
return
|
|
}
|
|
|
|
// Read and convert to base64
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
const dataUrl = e.target?.result as string
|
|
// dataUrl is "data:image/png;base64,XXXXXX"
|
|
const base64Data = dataUrl.split(',')[1]
|
|
|
|
const attachment: ImageAttachment = {
|
|
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
filename: file.name,
|
|
mimeType: file.type as 'image/jpeg' | 'image/png',
|
|
base64Data,
|
|
previewUrl: dataUrl,
|
|
size: file.size,
|
|
}
|
|
|
|
setPendingAttachments((prev) => [...prev, attachment])
|
|
}
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}, [])
|
|
|
|
const handleRemoveAttachment = useCallback((id: string) => {
|
|
setPendingAttachments((prev) => prev.filter((a) => a.id !== id))
|
|
}, [])
|
|
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
handleFileSelect(e.dataTransfer.files)
|
|
},
|
|
[handleFileSelect]
|
|
)
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
}, [])
|
|
|
|
// Connection status indicator
|
|
const ConnectionIndicator = () => {
|
|
switch (connectionStatus) {
|
|
case 'connected':
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
|
<Wifi size={12} />
|
|
Connected
|
|
</span>
|
|
)
|
|
case 'connecting':
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-yellow-500">
|
|
<Wifi size={12} className="animate-pulse" />
|
|
Connecting...
|
|
</span>
|
|
)
|
|
case 'error':
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-destructive">
|
|
<WifiOff size={12} />
|
|
Error
|
|
</span>
|
|
)
|
|
default:
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<WifiOff size={12} />
|
|
Disconnected
|
|
</span>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-background">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b-2 border-border bg-card">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="font-display font-bold text-lg text-foreground">
|
|
Create Spec: {projectName}
|
|
</h2>
|
|
<ConnectionIndicator />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{isComplete && (
|
|
<span className="flex items-center gap-1 text-sm text-green-500 font-bold">
|
|
<CheckCircle2 size={16} />
|
|
Complete
|
|
</span>
|
|
)}
|
|
|
|
{/* Load Sample Prompt */}
|
|
<Button
|
|
onClick={() => {
|
|
setInput(SAMPLE_PROMPT)
|
|
// Also resize the textarea to fit content
|
|
if (inputRef.current) {
|
|
inputRef.current.style.height = 'auto'
|
|
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 200)}px`
|
|
}
|
|
}}
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Load sample prompt (Simple Todo app)"
|
|
>
|
|
<FileText size={16} />
|
|
Load Sample
|
|
</Button>
|
|
|
|
{/* Exit to Project - always visible escape hatch */}
|
|
<Button
|
|
onClick={onExitToProject}
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Exit chat and go to project (you can start the agent manually)"
|
|
>
|
|
<ExternalLink size={16} />
|
|
Exit to Project
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={onCancel}
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Cancel"
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
{error && (
|
|
<Alert variant="destructive" className="rounded-none border-x-0 border-t-0">
|
|
<AlertCircle size={16} />
|
|
<AlertDescription className="flex-1">{error}</AlertDescription>
|
|
<Button
|
|
onClick={() => setError(null)}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Messages area */}
|
|
<div className="flex-1 overflow-y-auto py-4">
|
|
{messages.length === 0 && !isLoading && (
|
|
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
|
<Card className="p-6 max-w-md">
|
|
<CardContent className="p-0">
|
|
<h3 className="font-display font-bold text-lg mb-2">
|
|
Starting Spec Creation
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Connecting to Claude to help you create your app specification...
|
|
</p>
|
|
{connectionStatus === 'error' && (
|
|
<Button
|
|
onClick={start}
|
|
className="mt-4"
|
|
size="sm"
|
|
>
|
|
<RotateCcw size={14} />
|
|
Retry Connection
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((message) => (
|
|
<ChatMessage key={message.id} message={message} />
|
|
))}
|
|
|
|
{/* Structured questions */}
|
|
{currentQuestions && currentQuestions.length > 0 && (
|
|
<QuestionOptions
|
|
questions={currentQuestions}
|
|
onSubmit={handleAnswerSubmit}
|
|
disabled={isLoading}
|
|
/>
|
|
)}
|
|
|
|
{/* Typing indicator - don't show when we have questions (waiting for user) */}
|
|
{isLoading && !currentQuestions && <TypingIndicator />}
|
|
|
|
{/* Scroll anchor */}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input area */}
|
|
{!isComplete && (
|
|
<div
|
|
className="p-4 border-t-2 border-border bg-card"
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
>
|
|
{/* Attachment previews */}
|
|
{pendingAttachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
{pendingAttachments.map((attachment) => (
|
|
<div
|
|
key={attachment.id}
|
|
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
|
|
>
|
|
<img
|
|
src={attachment.previewUrl}
|
|
alt={attachment.filename}
|
|
className="w-16 h-16 object-cover rounded"
|
|
/>
|
|
<button
|
|
onClick={() => handleRemoveAttachment(attachment.id)}
|
|
className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
|
|
title="Remove attachment"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
<span className="text-xs truncate block max-w-16 mt-1 text-center text-muted-foreground">
|
|
{attachment.filename.length > 10
|
|
? `${attachment.filename.substring(0, 7)}...`
|
|
: attachment.filename}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
{/* Hidden file input */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png"
|
|
multiple
|
|
onChange={(e) => handleFileSelect(e.target.files)}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Attach button */}
|
|
<Button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={connectionStatus !== 'connected'}
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Attach image (JPEG, PNG - max 5MB)"
|
|
>
|
|
<Paperclip size={18} />
|
|
</Button>
|
|
|
|
<Textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => {
|
|
setInput(e.target.value)
|
|
// Auto-resize the textarea
|
|
e.target.style.height = 'auto'
|
|
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={
|
|
currentQuestions
|
|
? 'Or type a custom response...'
|
|
: pendingAttachments.length > 0
|
|
? 'Add a message with your image(s)...'
|
|
: 'Type your response... (or /exit to go to project)'
|
|
}
|
|
className="flex-1 resize-none min-h-[46px] max-h-[200px] overflow-y-auto"
|
|
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
|
|
rows={1}
|
|
/>
|
|
<Button
|
|
onClick={handleSendMessage}
|
|
disabled={
|
|
(!input.trim() && pendingAttachments.length === 0) ||
|
|
(isLoading && !currentQuestions) ||
|
|
connectionStatus !== 'connected'
|
|
}
|
|
className="px-6"
|
|
>
|
|
<Send size={18} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Help text */}
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Completion footer */}
|
|
{isComplete && (
|
|
<div className={`p-4 border-t-2 border-border ${
|
|
initializerStatus === 'error' ? 'bg-destructive' : 'bg-green-500'
|
|
}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{initializerStatus === 'starting' ? (
|
|
<>
|
|
<Loader2 size={20} className="animate-spin text-white" />
|
|
<span className="font-bold text-white">
|
|
Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}...
|
|
</span>
|
|
</>
|
|
) : initializerStatus === 'error' ? (
|
|
<>
|
|
<AlertCircle size={20} className="text-white" />
|
|
<span className="font-bold text-white">
|
|
{initializerError || 'Failed to start agent'}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle2 size={20} className="text-white" />
|
|
<span className="font-bold text-white">Specification created successfully!</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{initializerStatus === 'error' && onRetryInitializer && (
|
|
<Button
|
|
onClick={onRetryInitializer}
|
|
variant="secondary"
|
|
>
|
|
<RotateCcw size={14} />
|
|
Retry
|
|
</Button>
|
|
)}
|
|
{initializerStatus === 'idle' && (
|
|
<>
|
|
{/* YOLO Mode Toggle */}
|
|
<Button
|
|
onClick={() => setYoloEnabled(!yoloEnabled)}
|
|
variant={yoloEnabled ? "default" : "secondary"}
|
|
size="sm"
|
|
className={yoloEnabled ? 'bg-yellow-500 hover:bg-yellow-600 text-yellow-900' : ''}
|
|
title="YOLO Mode: Skip testing for rapid prototyping"
|
|
>
|
|
<Zap size={16} />
|
|
<span className={yoloEnabled ? 'font-bold' : ''}>
|
|
YOLO
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
onClick={() => onComplete('', yoloEnabled)}
|
|
>
|
|
Continue to Project
|
|
<ArrowRight size={16} />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|