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

@@ -473,7 +473,50 @@ After: **CRITICAL:** You must create exactly **25** features using the `feature
**Verify the update:** After editing, read the file again to confirm the feature count appears correctly. If `[FEATURE_COUNT]` still appears in the file, the update failed and you must try again. **Verify the update:** After editing, read the file again to confirm the feature count appears correctly. If `[FEATURE_COUNT]` still appears in the file, the update failed and you must try again.
**Note:** You do NOT need to update `coding_prompt.md` - the coding agent works through features one at a time regardless of total count. **Note:** You may also update `coding_prompt.md` if the user requests changes to how the coding agent should work. Include it in the status file if modified.
## 3. Write Status File (REQUIRED - Do This Last)
**Output path:** `$ARGUMENTS/prompts/.spec_status.json`
**CRITICAL:** After you have completed ALL requested file changes, write this status file to signal completion to the UI. This is required for the "Continue to Project" button to appear.
Write this JSON file:
```json
{
"status": "complete",
"version": 1,
"timestamp": "[current ISO 8601 timestamp, e.g., 2025-01-15T14:30:00.000Z]",
"files_written": [
"prompts/app_spec.txt",
"prompts/initializer_prompt.md"
],
"feature_count": [the feature count from Phase 4L]
}
```
**Include ALL files you modified** in the `files_written` array. If the user asked you to also modify `coding_prompt.md`, include it:
```json
{
"status": "complete",
"version": 1,
"timestamp": "2025-01-15T14:30:00.000Z",
"files_written": [
"prompts/app_spec.txt",
"prompts/initializer_prompt.md",
"prompts/coding_prompt.md"
],
"feature_count": 35
}
```
**IMPORTANT:**
- Write this file LAST, after all other files are successfully written
- Only write it when you consider ALL requested work complete
- The UI polls this file to detect completion and show the Continue button
- If the user asks for additional changes after you've written this, you may update it again when the new changes are complete
--- ---
@@ -487,7 +530,9 @@ Once files are generated, tell the user what to do next:
> - `$ARGUMENTS/prompts/app_spec.txt` > - `$ARGUMENTS/prompts/app_spec.txt`
> - `$ARGUMENTS/prompts/initializer_prompt.md` > - `$ARGUMENTS/prompts/initializer_prompt.md`
> >
> **Next step:** Type `/exit` to exit this Claude session. The autonomous coding agent will start automatically. > The **Continue to Project** button should now appear. Click it to start the autonomous coding agent!
>
> **If you don't see the button:** Type `/exit` or click **Exit to Project** in the header.
> >
> **Important timing expectations:** > **Important timing expectations:**
> >

View File

@@ -98,6 +98,67 @@ async def cancel_session(project_name: str):
return {"success": True, "message": "Session cancelled"} return {"success": True, "message": "Session cancelled"}
class SpecFileStatus(BaseModel):
"""Status of spec files on disk (from .spec_status.json)."""
exists: bool
status: str # "complete" | "in_progress" | "not_started"
feature_count: Optional[int] = None
timestamp: Optional[str] = None
files_written: list[str] = []
@router.get("/status/{project_name}", response_model=SpecFileStatus)
async def get_spec_file_status(project_name: str):
"""
Get spec creation status by reading .spec_status.json from the project.
This is used for polling to detect when Claude has finished writing spec files.
Claude writes this status file as the final step after completing all spec work.
"""
if not validate_project_name(project_name):
raise HTTPException(status_code=400, detail="Invalid project name")
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail="Project not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
status_file = project_dir / "prompts" / ".spec_status.json"
if not status_file.exists():
return SpecFileStatus(
exists=False,
status="not_started",
feature_count=None,
timestamp=None,
files_written=[],
)
try:
data = json.loads(status_file.read_text(encoding="utf-8"))
return SpecFileStatus(
exists=True,
status=data.get("status", "unknown"),
feature_count=data.get("feature_count"),
timestamp=data.get("timestamp"),
files_written=data.get("files_written", []),
)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in spec status file: {e}")
return SpecFileStatus(
exists=True,
status="error",
feature_count=None,
timestamp=None,
files_written=[],
)
except Exception as e:
logger.error(f"Error reading spec status file: {e}")
raise HTTPException(status_code=500, detail="Failed to read status file")
# ============================================================================ # ============================================================================
# WebSocket Endpoint # WebSocket Endpoint
# ============================================================================ # ============================================================================

View File

@@ -148,6 +148,12 @@ export function NewProjectModal({
setSpecMethod(null) setSpecMethod(null)
} }
const handleExitToProject = () => {
// Exit chat and go directly to project - user can start agent manually
onProjectCreated(projectName.trim())
handleClose()
}
const handleClose = () => { const handleClose = () => {
setStep('name') setStep('name')
setProjectName('') setProjectName('')
@@ -178,6 +184,7 @@ export function NewProjectModal({
projectName={projectName.trim()} projectName={projectName.trim()}
onComplete={handleSpecComplete} onComplete={handleSpecComplete}
onCancel={handleChatCancel} onCancel={handleChatCancel}
onExitToProject={handleExitToProject}
initializerStatus={initializerStatus} initializerStatus={initializerStatus}
initializerError={initializerError} initializerError={initializerError}
onRetryInitializer={handleRetryInitializer} onRetryInitializer={handleRetryInitializer}

View File

@@ -6,7 +6,7 @@
*/ */
import { useCallback, useEffect, useRef, useState } from 'react' 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 { useSpecChat } from '../hooks/useSpecChat'
import { ChatMessage } from './ChatMessage' import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions' import { QuestionOptions } from './QuestionOptions'
@@ -23,6 +23,7 @@ interface SpecCreationChatProps {
projectName: string projectName: string
onComplete: (specPath: string, yoloMode?: boolean) => void onComplete: (specPath: string, yoloMode?: boolean) => void
onCancel: () => void onCancel: () => void
onExitToProject: () => void // Exit to project without starting agent
initializerStatus?: InitializerStatus initializerStatus?: InitializerStatus
initializerError?: string | null initializerError?: string | null
onRetryInitializer?: () => void onRetryInitializer?: () => void
@@ -32,6 +33,7 @@ export function SpecCreationChat({
projectName, projectName,
onComplete, onComplete,
onCancel, onCancel,
onExitToProject,
initializerStatus = 'idle', initializerStatus = 'idle',
initializerError = null, initializerError = null,
onRetryInitializer, onRetryInitializer,
@@ -86,6 +88,13 @@ export function SpecCreationChat({
// Allow sending if there's text OR attachments // Allow sending if there's text OR attachments
if ((!trimmed && pendingAttachments.length === 0) || isLoading) return 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) sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
setInput('') setInput('')
setPendingAttachments([]) // Clear attachments after sending setPendingAttachments([]) // Clear attachments after sending
@@ -210,6 +219,16 @@ export function SpecCreationChat({
</span> </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 <button
onClick={onCancel} onClick={onCancel}
className="neo-btn neo-btn-ghost p-2" className="neo-btn neo-btn-ghost p-2"
@@ -347,7 +366,7 @@ export function SpecCreationChat({
? 'Or type a custom response...' ? 'Or type a custom response...'
: pendingAttachments.length > 0 : pendingAttachments.length > 0
? 'Add a message with your image(s)...' ? 'Add a message with your image(s)...'
: 'Type your response...' : 'Type your response... (or /exit to go to project)'
} }
className="neo-input flex-1" className="neo-input flex-1"
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'} disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}

View File

@@ -4,6 +4,7 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types' import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
import { getSpecStatus } from '../lib/api'
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' 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(() => { const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
return 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 // Setup API
// ============================================================================ // ============================================================================