Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ This launches the React-based web UI at `http://localhost:5173` with:
- Kanban board view of features
- Real-time agent output streaming
- Start/pause/stop controls
- **Project Assistant** - AI chat for managing features and exploring the codebase

### Option 2: CLI Mode

Expand Down Expand Up @@ -103,6 +104,21 @@ Features are stored in SQLite via SQLAlchemy and managed through an MCP server t
- `feature_mark_passing` - Mark feature complete
- `feature_skip` - Move feature to end of queue
- `feature_create_bulk` - Initialize all features (used by initializer)
- `feature_create` - Create a single feature

### Project Assistant

The Web UI includes a **Project Assistant** - an AI-powered chat interface for each project. Click the chat button in the bottom-right corner to open it.

**Capabilities:**
- **Explore the codebase** - Ask questions about files, architecture, and implementation details
- **Manage features** - Create new features and deprioritize (skip) existing ones via natural language
- **Get feature details** - Ask about specific features, their status, and test steps

**Conversation Persistence:**
- Conversations are automatically saved to `assistant.db` in each project directory
- When you navigate away and return, your conversation resumes where you left off
- Click "New Chat" to start a fresh conversation

### Session Management

Expand Down Expand Up @@ -151,8 +167,8 @@ autonomous-coding/
│ ├── main.py # FastAPI REST API server
│ ├── websocket.py # WebSocket handler for real-time updates
│ ├── schemas.py # Pydantic schemas
│ ├── routers/ # API route handlers
│ └── services/ # Business logic services
│ ├── routers/ # API route handlers (projects, features, agent, assistant)
│ └── services/ # Business logic (assistant chat sessions, database)
├── ui/ # React frontend
│ ├── src/
│ │ ├── App.tsx # Main app component
Expand All @@ -179,6 +195,7 @@ After the agent runs, your project directory will contain:
```
generations/my_project/
├── features.db # SQLite database (feature test cases)
├── assistant.db # SQLite database (assistant chat history)
├── prompts/
│ ├── app_spec.txt # Your app specification
│ ├── initializer_prompt.md # First session prompt
Expand Down
35 changes: 35 additions & 0 deletions server/routers/assistant_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,41 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
"content": f"Failed to start session: {str(e)}"
})

elif msg_type == "resume":
# Resume an existing conversation without sending greeting
conversation_id = message.get("conversation_id")

# Validate conversation_id is present and valid
if not conversation_id or not isinstance(conversation_id, int):
logger.warning(f"Invalid resume request for {project_name}: missing or invalid conversation_id")
await websocket.send_json({
"type": "error",
"content": "Missing or invalid conversation_id for resume"
})
continue

try:
# Create session
session = await create_session(
project_name,
project_dir,
conversation_id=conversation_id,
)
# Initialize but skip the greeting
async for chunk in session.start(skip_greeting=True):
await websocket.send_json(chunk)
# Confirm we're ready
await websocket.send_json({
"type": "conversation_created",
"conversation_id": conversation_id,
})
except Exception as e:
logger.exception(f"Error resuming assistant session for {project_name}")
await websocket.send_json({
"type": "error",
"content": f"Failed to resume session: {str(e)}"
})

elif msg_type == "message":
if not session:
session = get_session(project_name)
Expand Down
26 changes: 15 additions & 11 deletions server/services/assistant_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,15 @@ async def close(self) -> None:
self._client_entered = False
self.client = None

async def start(self) -> AsyncGenerator[dict, None]:
async def start(self, skip_greeting: bool = False) -> AsyncGenerator[dict, None]:
"""
Initialize session with the Claude client.

Creates a new conversation if none exists, then sends an initial greeting.
Yields message chunks as they stream in.

Args:
skip_greeting: If True, skip sending the greeting (for resuming conversations)
"""
# Create a new conversation if we don't have one
if self.conversation_id is None:
Expand Down Expand Up @@ -267,18 +270,19 @@ async def start(self) -> AsyncGenerator[dict, None]:
yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
return

# Send initial greeting
try:
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
# Send initial greeting (unless resuming)
if not skip_greeting:
try:
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, manage features (create and deprioritize), and answer questions about the project. What would you like to do?"

# Store the greeting in the database
add_message(self.project_dir, self.conversation_id, "assistant", greeting)
# Store the greeting in the database
add_message(self.project_dir, self.conversation_id, "assistant", greeting)

yield {"type": "text", "content": greeting}
yield {"type": "response_done"}
except Exception as e:
logger.exception("Failed to send greeting")
yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}
yield {"type": "text", "content": greeting}
yield {"type": "response_done"}
except Exception as e:
logger.exception("Failed to send greeting")
yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}

async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
"""
Expand Down
Empty file modified start_ui.sh
100644 → 100755
Empty file.
149 changes: 90 additions & 59 deletions ui/src/components/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,101 +3,119 @@
*
* Main chat interface for the project assistant.
* Displays messages and handles user input.
* Automatically resumes the most recent conversation.
*/

import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Loader2, Wifi, WifiOff } from 'lucide-react'
import { useAssistantChat } from '../hooks/useAssistantChat'
import { ChatMessage } from './ChatMessage'
import { useState, useRef, useEffect, useCallback } from "react";
import { Send, Loader2, Wifi, WifiOff, Plus } from "lucide-react";
import { useAssistantChat } from "../hooks/useAssistantChat";
import { ChatMessage } from "./ChatMessage";

interface AssistantChatProps {
projectName: string
projectName: string;
}

export function AssistantChat({ projectName }: AssistantChatProps) {
const [inputValue, setInputValue] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const hasStartedRef = useRef(false)
const [inputValue, setInputValue] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);

// Memoize the error handler to prevent infinite re-renders
const handleError = useCallback((error: string) => {
console.error('Assistant error:', error)
}, [])
console.error("Assistant error:", error);
}, []);

const {
messages,
isLoading,
connectionStatus,
start,
isLoadingHistory,
startNewConversation,
sendMessage,
} = useAssistantChat({
projectName,
onError: handleError,
})
});

// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

// Start the chat session when component mounts (only once)
useEffect(() => {
if (!hasStartedRef.current) {
hasStartedRef.current = true
start()
}
}, [start])
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);

// Focus input when not loading
useEffect(() => {
if (!isLoading) {
inputRef.current?.focus()
inputRef.current?.focus();
}
}, [isLoading])
}, [isLoading]);

const handleSend = () => {
const content = inputValue.trim()
if (!content || isLoading) return
const content = inputValue.trim();
if (!content || isLoading) return;

sendMessage(content)
setInputValue('')
}
sendMessage(content);
setInputValue("");
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
};

return (
<div className="flex flex-col h-full">
{/* Connection status indicator */}
<div className="flex items-center gap-2 px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
{connectionStatus === 'connected' ? (
<>
<Wifi size={14} className="text-[var(--color-neo-done)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connected</span>
</>
) : connectionStatus === 'connecting' ? (
<>
<Loader2 size={14} className="text-[var(--color-neo-progress)] animate-spin" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connecting...</span>
</>
) : (
<>
<WifiOff size={14} className="text-[var(--color-neo-danger)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Disconnected</span>
</>
)}
{/* Header with connection status and new chat button */}
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
<div className="flex items-center gap-2">
{connectionStatus === "connected" ? (
<>
<Wifi size={14} className="text-[var(--color-neo-done)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">
Connected
</span>
</>
) : connectionStatus === "connecting" ? (
<>
<Loader2
size={14}
className="text-[var(--color-neo-progress)] animate-spin"
/>
<span className="text-xs text-[var(--color-neo-text-secondary)]">
Connecting...
</span>
</>
) : (
<>
<WifiOff size={14} className="text-[var(--color-neo-danger)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">
Disconnected
</span>
</>
)}
</div>
<button
onClick={startNewConversation}
disabled={isLoading || isLoadingHistory}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)] hover:bg-[var(--color-neo-bg-alt)] rounded border border-[var(--color-neo-border)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Start a new conversation"
>
<Plus size={14} />
New Chat
</button>
</div>

{/* Messages area */}
<div className="flex-1 overflow-y-auto bg-[var(--color-neo-bg)]">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-secondary)] text-sm">
{isLoading ? (
{isLoadingHistory ? (
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" />
<span>Loading conversation...</span>
</div>
) : isLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" />
<span>Connecting to assistant...</span>
Expand All @@ -121,9 +139,18 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
<div className="px-4 py-2 border-t-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
<div className="flex items-center gap-2 text-[var(--color-neo-text-secondary)] text-sm">
<div className="flex gap-1">
<span className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
<span>Thinking...</span>
</div>
Expand All @@ -139,7 +166,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
disabled={isLoading || connectionStatus !== 'connected'}
disabled={isLoading || connectionStatus !== "connected"}
className="
flex-1
neo-input
Expand All @@ -152,7 +179,11 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading || connectionStatus !== 'connected'}
disabled={
!inputValue.trim() ||
isLoading ||
connectionStatus !== "connected"
}
className="
neo-btn neo-btn-primary
px-4
Expand All @@ -172,5 +203,5 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
</p>
</div>
</div>
)
);
}
Loading