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

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