From 908754302a8ef40d5821cbccdfa496f0f05aa889 Mon Sep 17 00:00:00 2001 From: Auto Date: Sun, 4 Jan 2026 14:57:58 +0200 Subject: [PATCH] feat: Add conversational AI assistant panel for project codebase Q&A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/main.py | 7 +- server/routers/__init__.py | 10 +- server/routers/assistant_chat.py | 340 ++++++++++++++++++++ server/services/assistant_chat_session.py | 374 ++++++++++++++++++++++ server/services/assistant_database.py | 219 +++++++++++++ ui/src/App.tsx | 30 +- ui/src/components/AssistantChat.tsx | 176 ++++++++++ ui/src/components/AssistantFAB.tsx | 35 ++ ui/src/components/AssistantPanel.tsx | 79 +++++ ui/src/hooks/useAssistantChat.ts | 286 +++++++++++++++++ ui/src/lib/api.ts | 39 +++ ui/src/lib/types.ts | 66 ++++ ui/tsconfig.tsbuildinfo | 2 +- 13 files changed, 1657 insertions(+), 6 deletions(-) create mode 100644 server/routers/assistant_chat.py create mode 100644 server/services/assistant_chat_session.py create mode 100644 server/services/assistant_database.py create mode 100644 ui/src/components/AssistantChat.tsx create mode 100644 ui/src/components/AssistantFAB.tsx create mode 100644 ui/src/components/AssistantPanel.tsx create mode 100644 ui/src/hooks/useAssistantChat.ts diff --git a/server/main.py b/server/main.py index 7f7e774..370a490 100644 --- a/server/main.py +++ b/server/main.py @@ -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) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 95f72c3..0cdd829 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -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", +] diff --git a/server/routers/assistant_chat.py b/server/routers/assistant_chat.py new file mode 100644 index 0000000..54bc4f4 --- /dev/null +++ b/server/routers/assistant_chat.py @@ -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 diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py new file mode 100644 index 0000000..8c648fb --- /dev/null +++ b/server/services/assistant_chat_session.py @@ -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}") diff --git a/server/services/assistant_database.py b/server/services/assistant_database.py new file mode 100644 index 0000000..75d1075 --- /dev/null +++ b/server/services/assistant_database.py @@ -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() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3c639b6..96066b2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 && ( + <> + setAssistantOpen(!assistantOpen)} + isOpen={assistantOpen} + /> + setAssistantOpen(false)} + /> + + )} ) } diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx new file mode 100644 index 0000000..ef8aeb3 --- /dev/null +++ b/ui/src/components/AssistantChat.tsx @@ -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(null) + const inputRef = useRef(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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( +
+ {/* Connection status indicator */} +
+ {connectionStatus === 'connected' ? ( + <> + + Connected + + ) : connectionStatus === 'connecting' ? ( + <> + + Connecting... + + ) : ( + <> + + Disconnected + + )} +
+ + {/* Messages area */} +
+ {messages.length === 0 ? ( +
+ {isLoading ? ( +
+ + Connecting to assistant... +
+ ) : ( + Ask me anything about the codebase + )} +
+ ) : ( +
+ {messages.map((message) => ( + + ))} +
+
+ )} +
+ + {/* Loading indicator */} + {isLoading && messages.length > 0 && ( +
+
+
+ + + +
+ Thinking... +
+
+ )} + + {/* Input area */} +
+
+