feat: Add conversational AI assistant panel for project codebase Q&A

Implement a slide-in chat panel that allows users to ask questions about
their codebase using Claude Opus 4.5 with read-only access to project files.

Backend changes:
- Add SQLAlchemy models for conversation persistence (assistant_database.py)
- Create AssistantChatSession with read-only Claude SDK client
- Add WebSocket endpoint for real-time chat streaming
- Include read-only MCP tools: feature_get_stats, feature_get_next, etc.

Frontend changes:
- Add floating action button (bottom-right) to toggle panel
- Create slide-in panel component (400px width)
- Implement WebSocket hook with reconnection logic
- Add keyboard shortcut 'A' to toggle assistant

Key features:
- Read-only access: Only Read, Glob, Grep, WebFetch, WebSearch tools
- Persistent history: Conversations saved to SQLite per project
- Real-time streaming: Text chunks streamed as Claude generates response
- Tool visibility: Shows when assistant is using tools to explore code

🤖 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-04 14:57:58 +02:00
parent 88951e454a
commit 908754302a
13 changed files with 1657 additions and 6 deletions

View File

@@ -15,9 +15,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .routers import projects_router, features_router, agent_router, spec_creation_router, filesystem_router
from .routers import projects_router, features_router, agent_router, spec_creation_router, filesystem_router, assistant_chat_router
from .websocket import project_websocket
from .services.process_manager import cleanup_all_managers
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .schemas import SetupStatus
@@ -31,8 +32,9 @@ async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown."""
# Startup
yield
# Shutdown - cleanup all running agents
# Shutdown - cleanup all running agents and assistant sessions
await cleanup_all_managers()
await cleanup_assistant_sessions()
# Create FastAPI app
@@ -83,6 +85,7 @@ app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
# ============================================================================

View File

@@ -10,5 +10,13 @@ from .features import router as features_router
from .agent import router as agent_router
from .spec_creation import router as spec_creation_router
from .filesystem import router as filesystem_router
from .assistant_chat import router as assistant_chat_router
__all__ = ["projects_router", "features_router", "agent_router", "spec_creation_router", "filesystem_router"]
__all__ = [
"projects_router",
"features_router",
"agent_router",
"spec_creation_router",
"filesystem_router",
"assistant_chat_router",
]

View File

@@ -0,0 +1,340 @@
"""
Assistant Chat Router
=====================
WebSocket and REST endpoints for the read-only project assistant.
"""
import json
import logging
import re
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
from pydantic import BaseModel
from ..services.assistant_chat_session import (
AssistantChatSession,
get_session,
create_session,
remove_session,
list_sessions,
)
from ..services.assistant_database import (
get_conversations,
get_conversation,
delete_conversation,
create_conversation,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/assistant", tags=["assistant-chat"])
# Root directory
ROOT_DIR = Path(__file__).parent.parent.parent
def _get_project_path(project_name: str) -> Optional[Path]:
"""Get project path from registry."""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import get_project_path
return get_project_path(project_name)
def validate_project_name(name: str) -> bool:
"""Validate project name to prevent path traversal."""
return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))
# ============================================================================
# Pydantic Models
# ============================================================================
class ConversationSummary(BaseModel):
"""Summary of a conversation."""
id: int
project_name: str
title: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
message_count: int
class ConversationMessageModel(BaseModel):
"""A message within a conversation."""
id: int
role: str
content: str
timestamp: Optional[str]
class ConversationDetail(BaseModel):
"""Full conversation with messages."""
id: int
project_name: str
title: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
messages: list[ConversationMessageModel]
class SessionInfo(BaseModel):
"""Active session information."""
project_name: str
conversation_id: Optional[int]
is_active: bool
# ============================================================================
# REST Endpoints - Conversation Management
# ============================================================================
@router.get("/conversations/{project_name}", response_model=list[ConversationSummary])
async def list_project_conversations(project_name: str):
"""List all conversations for a project."""
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 or not project_dir.exists():
raise HTTPException(status_code=404, detail="Project not found")
conversations = get_conversations(project_dir, project_name)
return [ConversationSummary(**c) for c in conversations]
@router.get("/conversations/{project_name}/{conversation_id}", response_model=ConversationDetail)
async def get_project_conversation(project_name: str, conversation_id: int):
"""Get a specific conversation with all messages."""
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 or not project_dir.exists():
raise HTTPException(status_code=404, detail="Project not found")
conversation = get_conversation(project_dir, conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
return ConversationDetail(
id=conversation["id"],
project_name=conversation["project_name"],
title=conversation["title"],
created_at=conversation["created_at"],
updated_at=conversation["updated_at"],
messages=[ConversationMessageModel(**m) for m in conversation["messages"]],
)
@router.post("/conversations/{project_name}", response_model=ConversationSummary)
async def create_project_conversation(project_name: str):
"""Create a new conversation for a project."""
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 or not project_dir.exists():
raise HTTPException(status_code=404, detail="Project not found")
conversation = create_conversation(project_dir, project_name)
return ConversationSummary(
id=conversation.id,
project_name=conversation.project_name,
title=conversation.title,
created_at=conversation.created_at.isoformat() if conversation.created_at else None,
updated_at=conversation.updated_at.isoformat() if conversation.updated_at else None,
message_count=0,
)
@router.delete("/conversations/{project_name}/{conversation_id}")
async def delete_project_conversation(project_name: str, conversation_id: int):
"""Delete a conversation."""
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 or not project_dir.exists():
raise HTTPException(status_code=404, detail="Project not found")
success = delete_conversation(project_dir, conversation_id)
if not success:
raise HTTPException(status_code=404, detail="Conversation not found")
return {"success": True, "message": "Conversation deleted"}
# ============================================================================
# REST Endpoints - Session Management
# ============================================================================
@router.get("/sessions", response_model=list[str])
async def list_active_sessions():
"""List all active assistant sessions."""
return list_sessions()
@router.get("/sessions/{project_name}", response_model=SessionInfo)
async def get_session_info(project_name: str):
"""Get information about an active session."""
if not validate_project_name(project_name):
raise HTTPException(status_code=400, detail="Invalid project name")
session = get_session(project_name)
if not session:
raise HTTPException(status_code=404, detail="No active session for this project")
return SessionInfo(
project_name=project_name,
conversation_id=session.get_conversation_id(),
is_active=True,
)
@router.delete("/sessions/{project_name}")
async def close_session(project_name: str):
"""Close an active session."""
if not validate_project_name(project_name):
raise HTTPException(status_code=400, detail="Invalid project name")
session = get_session(project_name)
if not session:
raise HTTPException(status_code=404, detail="No active session for this project")
await remove_session(project_name)
return {"success": True, "message": "Session closed"}
# ============================================================================
# WebSocket Endpoint
# ============================================================================
@router.websocket("/ws/{project_name}")
async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
"""
WebSocket endpoint for assistant chat.
Message protocol:
Client -> Server:
- {"type": "start", "conversation_id": int | null} - Start/resume session
- {"type": "message", "content": "..."} - Send user message
- {"type": "ping"} - Keep-alive ping
Server -> Client:
- {"type": "conversation_created", "conversation_id": int} - New conversation created
- {"type": "text", "content": "..."} - Text chunk from Claude
- {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
- {"type": "response_done"} - Response complete
- {"type": "error", "content": "..."} - Error message
- {"type": "pong"} - Keep-alive pong
"""
if not validate_project_name(project_name):
await websocket.close(code=4000, reason="Invalid project name")
return
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.close(code=4004, reason="Project not found in registry")
return
if not project_dir.exists():
await websocket.close(code=4004, reason="Project directory not found")
return
await websocket.accept()
logger.info(f"Assistant WebSocket connected for project: {project_name}")
session: Optional[AssistantChatSession] = None
try:
while True:
try:
data = await websocket.receive_text()
message = json.loads(data)
msg_type = message.get("type")
logger.info(f"Assistant received message type: {msg_type}")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
continue
elif msg_type == "start":
# Get optional conversation_id to resume
conversation_id = message.get("conversation_id")
try:
# Create a new session
session = await create_session(
project_name,
project_dir,
conversation_id=conversation_id,
)
# Stream the initial greeting
async for chunk in session.start():
await websocket.send_json(chunk)
except Exception as e:
logger.exception(f"Error starting assistant session for {project_name}")
await websocket.send_json({
"type": "error",
"content": f"Failed to start session: {str(e)}"
})
elif msg_type == "message":
if not session:
session = get_session(project_name)
if not session:
await websocket.send_json({
"type": "error",
"content": "No active session. Send 'start' first."
})
continue
user_content = message.get("content", "").strip()
if not user_content:
await websocket.send_json({
"type": "error",
"content": "Empty message"
})
continue
# Stream Claude's response
async for chunk in session.send_message(user_content):
await websocket.send_json(chunk)
else:
await websocket.send_json({
"type": "error",
"content": f"Unknown message type: {msg_type}"
})
except json.JSONDecodeError:
await websocket.send_json({
"type": "error",
"content": "Invalid JSON"
})
except WebSocketDisconnect:
logger.info(f"Assistant chat WebSocket disconnected for {project_name}")
except Exception as e:
logger.exception(f"Assistant chat WebSocket error for {project_name}")
try:
await websocket.send_json({
"type": "error",
"content": f"Server error: {str(e)}"
})
except Exception:
pass
finally:
# Don't remove session on disconnect - allow resume
pass

View File

@@ -0,0 +1,374 @@
"""
Assistant Chat Session
======================
Manages read-only conversational assistant sessions for projects.
The assistant can answer questions about the codebase and features
but cannot modify any files.
"""
import json
import logging
import os
import shutil
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from .assistant_database import (
create_conversation,
add_message,
get_conversation,
)
logger = logging.getLogger(__name__)
# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent
# Read-only feature MCP tools (no mark_passing, skip, create_bulk)
READONLY_FEATURE_MCP_TOOLS = [
"mcp__features__feature_get_stats",
"mcp__features__feature_get_next",
"mcp__features__feature_get_for_regression",
]
# Read-only built-in tools (no Write, Edit, Bash)
READONLY_BUILTIN_TOOLS = [
"Read",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
]
def get_system_prompt(project_name: str, project_dir: Path) -> str:
"""Generate the system prompt for the assistant with project context."""
# Try to load app_spec.txt for context
app_spec_content = ""
app_spec_path = project_dir / "prompts" / "app_spec.txt"
if app_spec_path.exists():
try:
app_spec_content = app_spec_path.read_text(encoding="utf-8")
# Truncate if too long
if len(app_spec_content) > 5000:
app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
except Exception as e:
logger.warning(f"Failed to read app_spec.txt: {e}")
return f"""You are a helpful project assistant for the "{project_name}" project.
Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files.
IMPORTANT: You CANNOT modify any files. You can only:
- Read and analyze source code files
- Search for patterns in the codebase
- Look up documentation online
- Check feature progress and status
If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications.
## Project Specification
{app_spec_content if app_spec_content else "(No app specification found)"}
## Available Tools
You have access to these read-only tools:
- **Read**: Read file contents
- **Glob**: Find files by pattern (e.g., "**/*.tsx")
- **Grep**: Search file contents with regex
- **WebFetch/WebSearch**: Look up documentation online
- **feature_get_stats**: Get feature completion progress
- **feature_get_next**: See the next pending feature
- **feature_get_for_regression**: See passing features
## Guidelines
1. Be concise and helpful
2. When explaining code, reference specific file paths and line numbers
3. Use the feature tools to answer questions about project progress
4. Search the codebase to find relevant information before answering
5. If you're unsure, say so rather than guessing"""
class AssistantChatSession:
"""
Manages a read-only assistant conversation for a project.
Uses Claude Opus 4.5 with only read-only tools enabled.
Persists conversation history to SQLite.
"""
def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
"""
Initialize the session.
Args:
project_name: Name of the project
project_dir: Absolute path to the project directory
conversation_id: Optional existing conversation ID to resume
"""
self.project_name = project_name
self.project_dir = project_dir
self.conversation_id = conversation_id
self.client: Optional[ClaudeSDKClient] = None
self._client_entered: bool = False
self.created_at = datetime.now()
async def close(self) -> None:
"""Clean up resources and close the Claude client."""
if self.client and self._client_entered:
try:
await self.client.__aexit__(None, None, None)
except Exception as e:
logger.warning(f"Error closing Claude client: {e}")
finally:
self._client_entered = False
self.client = None
async def start(self) -> 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.
"""
# Create a new conversation if we don't have one
if self.conversation_id is None:
conv = create_conversation(self.project_dir, self.project_name)
self.conversation_id = conv.id
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
# Build permissions list for read-only access
permissions_list = [
"Read(./**)",
"Glob(./**)",
"Grep(./**)",
"WebFetch",
"WebSearch",
*READONLY_FEATURE_MCP_TOOLS,
]
# Create security settings file
security_settings = {
"sandbox": {"enabled": False}, # No bash, so sandbox not needed
"permissions": {
"defaultMode": "bypassPermissions", # Read-only, no dangerous ops
"allow": permissions_list,
},
}
settings_file = self.project_dir / ".claude_assistant_settings.json"
with open(settings_file, "w") as f:
json.dump(security_settings, f, indent=2)
# Build MCP servers config - only features MCP for read-only access
mcp_servers = {
"features": {
"command": sys.executable,
"args": ["-m", "mcp_server.feature_mcp"],
"env": {
**os.environ,
"PROJECT_DIR": str(self.project_dir.resolve()),
"PYTHONPATH": str(ROOT_DIR.resolve()),
},
},
}
# Get system prompt with project context
system_prompt = get_system_prompt(self.project_name, self.project_dir)
# Use system Claude CLI
system_cli = shutil.which("claude")
try:
self.client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-opus-4-5-20251101",
cli_path=system_cli,
system_prompt=system_prompt,
allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS],
mcp_servers=mcp_servers,
permission_mode="bypassPermissions",
max_turns=100,
cwd=str(self.project_dir.resolve()),
settings=str(settings_file.resolve()),
)
)
await self.client.__aenter__()
self._client_entered = True
except Exception as e:
logger.exception("Failed to create Claude client")
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?"
# 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)}"}
async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
"""
Send user message and stream Claude's response.
Args:
user_message: The user's message
Yields:
Message chunks:
- {"type": "text", "content": str}
- {"type": "tool_call", "tool": str, "input": dict}
- {"type": "response_done"}
- {"type": "error", "content": str}
"""
if not self.client:
yield {"type": "error", "content": "Session not initialized. Call start() first."}
return
if self.conversation_id is None:
yield {"type": "error", "content": "No conversation ID set."}
return
# Store user message in database
add_message(self.project_dir, self.conversation_id, "user", user_message)
try:
async for chunk in self._query_claude(user_message):
yield chunk
yield {"type": "response_done"}
except Exception as e:
logger.exception("Error during Claude query")
yield {"type": "error", "content": f"Error: {str(e)}"}
async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
"""
Internal method to query Claude and stream responses.
Handles tool calls and text responses.
"""
if not self.client:
return
# Send message to Claude
await self.client.query(message)
full_response = ""
# Stream the response
async for msg in self.client.receive_response():
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
text = block.text
if text:
full_response += text
yield {"type": "text", "content": text}
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input = getattr(block, "input", {})
yield {
"type": "tool_call",
"tool": tool_name,
"input": tool_input,
}
# Store the complete response in the database
if full_response and self.conversation_id:
add_message(self.project_dir, self.conversation_id, "assistant", full_response)
def get_conversation_id(self) -> Optional[int]:
"""Get the current conversation ID."""
return self.conversation_id
# Session registry with thread safety
_sessions: dict[str, AssistantChatSession] = {}
_sessions_lock = threading.Lock()
def get_session(project_name: str) -> Optional[AssistantChatSession]:
"""Get an existing session for a project."""
with _sessions_lock:
return _sessions.get(project_name)
async def create_session(
project_name: str,
project_dir: Path,
conversation_id: Optional[int] = None
) -> AssistantChatSession:
"""
Create a new session for a project, closing any existing one.
Args:
project_name: Name of the project
project_dir: Absolute path to the project directory
conversation_id: Optional conversation ID to resume
"""
old_session: Optional[AssistantChatSession] = None
with _sessions_lock:
old_session = _sessions.pop(project_name, None)
session = AssistantChatSession(project_name, project_dir, conversation_id)
_sessions[project_name] = session
if old_session:
try:
await old_session.close()
except Exception as e:
logger.warning(f"Error closing old session for {project_name}: {e}")
return session
async def remove_session(project_name: str) -> None:
"""Remove and close a session."""
session: Optional[AssistantChatSession] = None
with _sessions_lock:
session = _sessions.pop(project_name, None)
if session:
try:
await session.close()
except Exception as e:
logger.warning(f"Error closing session for {project_name}: {e}")
def list_sessions() -> list[str]:
"""List all active session project names."""
with _sessions_lock:
return list(_sessions.keys())
async def cleanup_all_sessions() -> None:
"""Close all active sessions. Called on server shutdown."""
sessions_to_close: list[AssistantChatSession] = []
with _sessions_lock:
sessions_to_close = list(_sessions.values())
_sessions.clear()
for session in sessions_to_close:
try:
await session.close()
except Exception as e:
logger.warning(f"Error closing session {session.project_name}: {e}")

View File

@@ -0,0 +1,219 @@
"""
Assistant Database
==================
SQLAlchemy models and functions for persisting assistant conversations.
Each project has its own assistant.db file in the project directory.
"""
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship, declarative_base
logger = logging.getLogger(__name__)
Base = declarative_base()
class Conversation(Base):
"""A conversation with the assistant for a project."""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
project_name = Column(String(100), nullable=False, index=True)
title = Column(String(200), nullable=True) # Optional title, derived from first message
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")
class ConversationMessage(Base):
"""A single message within a conversation."""
__tablename__ = "conversation_messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
role = Column(String(20), nullable=False) # "user" | "assistant" | "system"
content = Column(Text, nullable=False)
timestamp = Column(DateTime, default=datetime.utcnow)
conversation = relationship("Conversation", back_populates="messages")
def get_db_path(project_dir: Path) -> Path:
"""Get the path to the assistant database for a project."""
return project_dir / "assistant.db"
def get_engine(project_dir: Path):
"""Get or create a SQLAlchemy engine for a project's assistant database."""
db_path = get_db_path(project_dir)
# Use as_posix() for cross-platform compatibility with SQLite connection strings
db_url = f"sqlite:///{db_path.as_posix()}"
engine = create_engine(db_url, echo=False)
Base.metadata.create_all(engine)
return engine
def get_session(project_dir: Path):
"""Get a new database session for a project."""
engine = get_engine(project_dir)
Session = sessionmaker(bind=engine)
return Session()
# ============================================================================
# Conversation Operations
# ============================================================================
def create_conversation(project_dir: Path, project_name: str, title: Optional[str] = None) -> Conversation:
"""Create a new conversation for a project."""
session = get_session(project_dir)
try:
conversation = Conversation(
project_name=project_name,
title=title,
)
session.add(conversation)
session.commit()
session.refresh(conversation)
logger.info(f"Created conversation {conversation.id} for project {project_name}")
return conversation
finally:
session.close()
def get_conversations(project_dir: Path, project_name: str) -> list[dict]:
"""Get all conversations for a project with message counts."""
session = get_session(project_dir)
try:
conversations = (
session.query(Conversation)
.filter(Conversation.project_name == project_name)
.order_by(Conversation.updated_at.desc())
.all()
)
return [
{
"id": c.id,
"project_name": c.project_name,
"title": c.title,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
"message_count": len(c.messages),
}
for c in conversations
]
finally:
session.close()
def get_conversation(project_dir: Path, conversation_id: int) -> Optional[dict]:
"""Get a conversation with all its messages."""
session = get_session(project_dir)
try:
conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
if not conversation:
return None
return {
"id": conversation.id,
"project_name": conversation.project_name,
"title": conversation.title,
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"timestamp": m.timestamp.isoformat() if m.timestamp else None,
}
for m in sorted(conversation.messages, key=lambda x: x.timestamp or datetime.min)
],
}
finally:
session.close()
def delete_conversation(project_dir: Path, conversation_id: int) -> bool:
"""Delete a conversation and all its messages."""
session = get_session(project_dir)
try:
conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
if not conversation:
return False
session.delete(conversation)
session.commit()
logger.info(f"Deleted conversation {conversation_id}")
return True
finally:
session.close()
# ============================================================================
# Message Operations
# ============================================================================
def add_message(project_dir: Path, conversation_id: int, role: str, content: str) -> Optional[dict]:
"""Add a message to a conversation."""
session = get_session(project_dir)
try:
conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
if not conversation:
return None
message = ConversationMessage(
conversation_id=conversation_id,
role=role,
content=content,
)
session.add(message)
# Update conversation's updated_at timestamp
conversation.updated_at = datetime.utcnow()
# Auto-generate title from first user message if not set
if not conversation.title and role == "user":
# Take first 50 chars of first user message as title
conversation.title = content[:50] + ("..." if len(content) > 50 else "")
session.commit()
session.refresh(message)
logger.debug(f"Added {role} message to conversation {conversation_id}")
return {
"id": message.id,
"role": message.role,
"content": message.content,
"timestamp": message.timestamp.isoformat() if message.timestamp else None,
}
finally:
session.close()
def get_messages(project_dir: Path, conversation_id: int) -> list[dict]:
"""Get all messages for a conversation."""
session = get_session(project_dir)
try:
messages = (
session.query(ConversationMessage)
.filter(ConversationMessage.conversation_id == conversation_id)
.order_by(ConversationMessage.timestamp.asc())
.all()
)
return [
{
"id": m.id,
"role": m.role,
"content": m.content,
"timestamp": m.timestamp.isoformat() if m.timestamp else None,
}
for m in messages
]
finally:
session.close()

View File

@@ -14,6 +14,8 @@ import { AddFeatureForm } from './components/AddFeatureForm'
import { FeatureModal } from './components/FeatureModal'
import { DebugLogViewer } from './components/DebugLogViewer'
import { AgentThought } from './components/AgentThought'
import { AssistantFAB } from './components/AssistantFAB'
import { AssistantPanel } from './components/AssistantPanel'
import { Plus, Loader2 } from 'lucide-react'
import type { Feature } from './lib/types'
@@ -31,6 +33,7 @@ function App() {
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
const [debugOpen, setDebugOpen] = useState(false)
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
const [assistantOpen, setAssistantOpen] = useState(false)
const { data: projects, isLoading: projectsLoading } = useProjects()
const { data: features } = useFeatures(selectedProject)
@@ -84,9 +87,17 @@ function App() {
setShowAddFeature(true)
}
// A : Toggle assistant panel (when project selected)
if ((e.key === 'a' || e.key === 'A') && selectedProject) {
e.preventDefault()
setAssistantOpen(prev => !prev)
}
// Escape : Close modals
if (e.key === 'Escape') {
if (showAddFeature) {
if (assistantOpen) {
setAssistantOpen(false)
} else if (showAddFeature) {
setShowAddFeature(false)
} else if (selectedFeature) {
setSelectedFeature(null)
@@ -98,7 +109,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, selectedFeature, debugOpen])
}, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen])
// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
@@ -244,6 +255,21 @@ function App() {
onHeightChange={setDebugPanelHeight}
/>
)}
{/* Assistant FAB and Panel */}
{selectedProject && (
<>
<AssistantFAB
onClick={() => setAssistantOpen(!assistantOpen)}
isOpen={assistantOpen}
/>
<AssistantPanel
projectName={selectedProject}
isOpen={assistantOpen}
onClose={() => setAssistantOpen(false)}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,176 @@
/**
* Assistant Chat Component
*
* Main chat interface for the project assistant.
* Displays messages and handles user input.
*/
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Loader2, Wifi, WifiOff } from 'lucide-react'
import { useAssistantChat } from '../hooks/useAssistantChat'
import { ChatMessage } from './ChatMessage'
interface AssistantChatProps {
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)
// Memoize the error handler to prevent infinite re-renders
const handleError = useCallback((error: string) => {
console.error('Assistant error:', error)
}, [])
const {
messages,
isLoading,
connectionStatus,
start,
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])
// Focus input when not loading
useEffect(() => {
if (!isLoading) {
inputRef.current?.focus()
}
}, [isLoading])
const handleSend = () => {
const content = inputValue.trim()
if (!content || isLoading) return
sendMessage(content)
setInputValue('')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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>
</>
)}
</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 ? (
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" />
<span>Connecting to assistant...</span>
</div>
) : (
<span>Ask me anything about the codebase</span>
)}
</div>
) : (
<div className="py-4">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Loading indicator */}
{isLoading && messages.length > 0 && (
<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' }} />
</div>
<span>Thinking...</span>
</div>
</div>
)}
{/* Input area */}
<div className="border-t-3 border-[var(--color-neo-border)] p-4 bg-white">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
disabled={isLoading || connectionStatus !== 'connected'}
className="
flex-1
neo-input
resize-none
min-h-[44px]
max-h-[120px]
py-2.5
"
rows={1}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading || connectionStatus !== 'connected'}
className="
neo-btn neo-btn-primary
px-4
disabled:opacity-50 disabled:cursor-not-allowed
"
title="Send message"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
<p className="text-xs text-[var(--color-neo-text-secondary)] mt-2">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
/**
* Floating Action Button for toggling the Assistant panel
*/
import { MessageCircle, X } from 'lucide-react'
interface AssistantFABProps {
onClick: () => void
isOpen: boolean
}
export function AssistantFAB({ onClick, isOpen }: AssistantFABProps) {
return (
<button
onClick={onClick}
className={`
fixed bottom-6 right-6 z-50
w-14 h-14
flex items-center justify-center
bg-[var(--color-neo-progress)] text-white
border-3 border-[var(--color-neo-border)]
rounded-full
shadow-neo-md
transition-all duration-200
hover:shadow-neo-lg hover:-translate-y-0.5
active:shadow-neo-sm active:translate-y-0.5
${isOpen ? 'rotate-0' : ''}
`}
title={isOpen ? 'Close Assistant (Press A)' : 'Open Assistant (Press A)'}
aria-label={isOpen ? 'Close Assistant' : 'Open Assistant'}
>
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
</button>
)
}

View File

@@ -0,0 +1,79 @@
/**
* Assistant Panel Component
*
* Slide-in panel container for the project assistant chat.
* Slides in from the right side of the screen.
*/
import { X, Bot } from 'lucide-react'
import { AssistantChat } from './AssistantChat'
interface AssistantPanelProps {
projectName: string
isOpen: boolean
onClose: () => void
}
export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelProps) {
return (
<>
{/* Backdrop - click to close */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 z-40 transition-opacity duration-300"
onClick={onClose}
aria-hidden="true"
/>
)}
{/* Panel */}
<div
className={`
fixed right-0 top-0 bottom-0 z-50
w-[400px] max-w-[90vw]
bg-white
border-l-4 border-[var(--color-neo-border)]
shadow-[-8px_0_0px_rgba(0,0,0,1)]
transform transition-transform duration-300 ease-out
flex flex-col
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
role="dialog"
aria-label="Project Assistant"
aria-hidden={!isOpen}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b-3 border-[var(--color-neo-border)] bg-[var(--color-neo-progress)]">
<div className="flex items-center gap-2">
<div className="bg-white border-2 border-[var(--color-neo-border)] p-1.5 shadow-[2px_2px_0px_rgba(0,0,0,1)]">
<Bot size={18} />
</div>
<div>
<h2 className="font-display font-bold text-white">Project Assistant</h2>
<p className="text-xs text-white/80 font-mono">{projectName}</p>
</div>
</div>
<button
onClick={onClose}
className="
neo-btn neo-btn-ghost
p-2
bg-white/20 border-white/40
hover:bg-white/30
text-white
"
title="Close Assistant (Press A)"
aria-label="Close Assistant"
>
<X size={18} />
</button>
</div>
{/* Chat area */}
<div className="flex-1 overflow-hidden">
{isOpen && <AssistantChat projectName={projectName} />}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,286 @@
/**
* Hook for managing assistant chat WebSocket connection
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, AssistantChatServerMessage } from '../lib/types'
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
interface UseAssistantChatOptions {
projectName: string
onError?: (error: string) => void
}
interface UseAssistantChatReturn {
messages: ChatMessage[]
isLoading: boolean
connectionStatus: ConnectionStatus
conversationId: number | null
start: (conversationId?: number | null) => void
sendMessage: (content: string) => void
disconnect: () => void
clearMessages: () => void
}
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
export function useAssistantChat({
projectName,
onError,
}: UseAssistantChatOptions): UseAssistantChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
const [conversationId, setConversationId] = useState<number | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const currentAssistantMessageRef = useRef<string | null>(null)
const reconnectAttempts = useRef(0)
const maxReconnectAttempts = 3
const pingIntervalRef = useRef<number | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
// Clean up on unmount
useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
}
}, [])
const connect = useCallback(() => {
// Prevent multiple connection attempts
if (wsRef.current?.readyState === WebSocket.OPEN ||
wsRef.current?.readyState === WebSocket.CONNECTING) {
return
}
setConnectionStatus('connecting')
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}`
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
setConnectionStatus('connected')
reconnectAttempts.current = 0
// Start ping interval to keep connection alive
pingIntervalRef.current = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
}, 30000)
}
ws.onclose = () => {
setConnectionStatus('disconnected')
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
}
// Attempt reconnection if not intentionally closed
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
reconnectTimeoutRef.current = window.setTimeout(connect, delay)
}
}
ws.onerror = () => {
setConnectionStatus('error')
onError?.('WebSocket connection error')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as AssistantChatServerMessage
switch (data.type) {
case 'text': {
// Append text to current assistant message or create new one
setMessages((prev) => {
const lastMessage = prev[prev.length - 1]
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
// Append to existing streaming message
return [
...prev.slice(0, -1),
{
...lastMessage,
content: lastMessage.content + data.content,
},
]
} else {
// Create new assistant message
currentAssistantMessageRef.current = generateId()
return [
...prev,
{
id: currentAssistantMessageRef.current,
role: 'assistant',
content: data.content,
timestamp: new Date(),
isStreaming: true,
},
]
}
})
break
}
case 'tool_call': {
// Show tool call as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'system',
content: `Using tool: ${data.tool}`,
timestamp: new Date(),
},
])
break
}
case 'conversation_created': {
setConversationId(data.conversation_id)
break
}
case 'response_done': {
setIsLoading(false)
// Mark current message as done streaming
setMessages((prev) => {
const lastMessage = prev[prev.length - 1]
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
return [
...prev.slice(0, -1),
{ ...lastMessage, isStreaming: false },
]
}
return prev
})
break
}
case 'error': {
setIsLoading(false)
onError?.(data.content)
// Add error as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'system',
content: `Error: ${data.content}`,
timestamp: new Date(),
},
])
break
}
case 'pong': {
// Keep-alive response, nothing to do
break
}
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
}, [projectName, onError])
const start = useCallback((existingConversationId?: number | null) => {
connect()
// Wait for connection then send start message
const checkAndSend = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
setIsLoading(true)
const payload: { type: string; conversation_id?: number } = { type: 'start' }
if (existingConversationId) {
payload.conversation_id = existingConversationId
setConversationId(existingConversationId)
}
wsRef.current.send(JSON.stringify(payload))
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
setTimeout(checkAndSend, 100)
}
}
setTimeout(checkAndSend, 100)
}, [connect])
const sendMessage = useCallback((content: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected')
return
}
// Add user message to chat
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'user',
content,
timestamp: new Date(),
},
])
setIsLoading(true)
// Send to server
wsRef.current.send(
JSON.stringify({
type: 'message',
content,
})
)
}, [onError])
const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setConnectionStatus('disconnected')
}, [])
const clearMessages = useCallback(() => {
setMessages([])
setConversationId(null)
}, [])
return {
messages,
isLoading,
connectionStatus,
conversationId,
start,
sendMessage,
disconnect,
clearMessages,
}
}

View File

@@ -14,6 +14,8 @@ import type {
SetupStatus,
DirectoryListResponse,
PathValidationResponse,
AssistantConversation,
AssistantConversationDetail,
} from './types'
const API_BASE = '/api'
@@ -228,3 +230,40 @@ export async function validatePath(path: string): Promise<PathValidationResponse
body: JSON.stringify({ path }),
})
}
// ============================================================================
// Assistant Chat API
// ============================================================================
export async function listAssistantConversations(
projectName: string
): Promise<AssistantConversation[]> {
return fetchJSON(`/assistant/conversations/${encodeURIComponent(projectName)}`)
}
export async function getAssistantConversation(
projectName: string,
conversationId: number
): Promise<AssistantConversationDetail> {
return fetchJSON(
`/assistant/conversations/${encodeURIComponent(projectName)}/${conversationId}`
)
}
export async function createAssistantConversation(
projectName: string
): Promise<AssistantConversation> {
return fetchJSON(`/assistant/conversations/${encodeURIComponent(projectName)}`, {
method: 'POST',
})
}
export async function deleteAssistantConversation(
projectName: string,
conversationId: number
): Promise<void> {
await fetchJSON(
`/assistant/conversations/${encodeURIComponent(projectName)}/${conversationId}`,
{ method: 'DELETE' }
)
}

View File

@@ -229,3 +229,69 @@ export interface ChatMessage {
questions?: SpecQuestion[]
isStreaming?: boolean
}
// ============================================================================
// Assistant Chat Types
// ============================================================================
export interface AssistantConversation {
id: number
project_name: string
title: string | null
created_at: string | null
updated_at: string | null
message_count: number
}
export interface AssistantMessage {
id: number
role: 'user' | 'assistant' | 'system'
content: string
timestamp: string | null
}
export interface AssistantConversationDetail {
id: number
project_name: string
title: string | null
created_at: string | null
updated_at: string | null
messages: AssistantMessage[]
}
export interface AssistantChatTextMessage {
type: 'text'
content: string
}
export interface AssistantChatToolCallMessage {
type: 'tool_call'
tool: string
input: Record<string, unknown>
}
export interface AssistantChatResponseDoneMessage {
type: 'response_done'
}
export interface AssistantChatErrorMessage {
type: 'error'
content: string
}
export interface AssistantChatConversationCreatedMessage {
type: 'conversation_created'
conversation_id: number
}
export interface AssistantChatPongMessage {
type: 'pong'
}
export type AssistantChatServerMessage =
| AssistantChatTextMessage
| AssistantChatToolCallMessage
| AssistantChatResponseDoneMessage
| AssistantChatErrorMessage
| AssistantChatConversationCreatedMessage
| AssistantChatPongMessage

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}