mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: Add robust fallback mechanisms for spec creation chat
Add multiple escape hatches to prevent users from getting stuck during
spec creation when the WebSocket completion signal fails.
Changes:
- Add "Exit to Project" button always visible in chat header
- Add /exit command detection to immediately exit to project
- Add backend GET /api/spec/status/{project} endpoint to poll status file
- Add getSpecStatus() API function in frontend
- Add status file polling (every 3s) in useSpecChat hook
- Update create-spec.md with status file write instructions
How it works:
1. Happy path: Claude writes .spec_status.json as final step, UI polls
and detects completion, shows "Continue to Project" button
2. Escape hatch: User can always click "Exit to Project" or type /exit
to instantly select the project and close modal, then manually start
the agent from the main UI
This ensures users always have a way forward even if the WebSocket
completion detection fails due to tool call tracking issues.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,12 @@ export function NewProjectModal({
|
||||
setSpecMethod(null)
|
||||
}
|
||||
|
||||
const handleExitToProject = () => {
|
||||
// Exit chat and go directly to project - user can start agent manually
|
||||
onProjectCreated(projectName.trim())
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setStep('name')
|
||||
setProjectName('')
|
||||
@@ -178,6 +184,7 @@ export function NewProjectModal({
|
||||
projectName={projectName.trim()}
|
||||
onComplete={handleSpecComplete}
|
||||
onCancel={handleChatCancel}
|
||||
onExitToProject={handleExitToProject}
|
||||
initializerStatus={initializerStatus}
|
||||
initializerError={initializerError}
|
||||
onRetryInitializer={handleRetryInitializer}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip } from 'lucide-react'
|
||||
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink } from 'lucide-react'
|
||||
import { useSpecChat } from '../hooks/useSpecChat'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
import { QuestionOptions } from './QuestionOptions'
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
@@ -32,6 +33,7 @@ export function SpecCreationChat({
|
||||
projectName,
|
||||
onComplete,
|
||||
onCancel,
|
||||
onExitToProject,
|
||||
initializerStatus = 'idle',
|
||||
initializerError = null,
|
||||
onRetryInitializer,
|
||||
@@ -86,6 +88,13 @@ export function SpecCreationChat({
|
||||
// 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
|
||||
@@ -210,6 +219,16 @@ export function SpecCreationChat({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Exit to Project - always visible escape hatch */}
|
||||
<button
|
||||
onClick={onExitToProject}
|
||||
className="neo-btn neo-btn-ghost text-sm py-2"
|
||||
title="Exit chat and go to project (you can start the agent manually)"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
Exit to Project
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
@@ -347,7 +366,7 @@ export function SpecCreationChat({
|
||||
? 'Or type a custom response...'
|
||||
: pendingAttachments.length > 0
|
||||
? 'Add a message with your image(s)...'
|
||||
: 'Type your response...'
|
||||
: 'Type your response... (or /exit to go to project)'
|
||||
}
|
||||
className="neo-input flex-1"
|
||||
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
|
||||
import { getSpecStatus } from '../lib/api'
|
||||
|
||||
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
@@ -70,6 +71,66 @@ export function useSpecChat({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Poll status file as fallback completion detection
|
||||
// Claude writes .spec_status.json when done with all spec work
|
||||
useEffect(() => {
|
||||
// Don't poll if already complete
|
||||
if (isComplete) return
|
||||
|
||||
// Start polling after initial delay (let WebSocket try first)
|
||||
const startDelay = setTimeout(() => {
|
||||
const pollInterval = setInterval(async () => {
|
||||
// Stop if already complete
|
||||
if (isCompleteRef.current) {
|
||||
clearInterval(pollInterval)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getSpecStatus(projectName)
|
||||
|
||||
if (status.exists && status.status === 'complete') {
|
||||
// Status file indicates completion - set complete state
|
||||
setIsComplete(true)
|
||||
setIsLoading(false)
|
||||
|
||||
// Mark any streaming message as done
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1]
|
||||
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...lastMessage, isStreaming: false },
|
||||
]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
|
||||
// Add system message about completion
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'system',
|
||||
content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore polling errors - WebSocket is primary mechanism
|
||||
}
|
||||
}, 3000) // Poll every 3 seconds
|
||||
|
||||
// Cleanup interval on unmount
|
||||
return () => clearInterval(pollInterval)
|
||||
}, 3000) // Start polling after 3 second delay
|
||||
|
||||
return () => clearTimeout(startDelay)
|
||||
}, [projectName, isComplete])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
|
||||
@@ -145,6 +145,22 @@ export async function resumeAgent(projectName: string): Promise<AgentActionRespo
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Spec Creation API
|
||||
// ============================================================================
|
||||
|
||||
export interface SpecFileStatus {
|
||||
exists: boolean
|
||||
status: 'complete' | 'in_progress' | 'not_started' | 'error' | 'unknown'
|
||||
feature_count: number | null
|
||||
timestamp: string | null
|
||||
files_written: string[]
|
||||
}
|
||||
|
||||
export async function getSpecStatus(projectName: string): Promise<SpecFileStatus> {
|
||||
return fetchJSON(`/spec/status/${encodeURIComponent(projectName)}`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Setup API
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user