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:
Auto
2026-01-02 10:54:42 +02:00
parent 74d3bd9d8b
commit e8f3b99a42
6 changed files with 213 additions and 4 deletions

View File

@@ -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}

View File

@@ -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'}

View File

@@ -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

View File

@@ -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
// ============================================================================