feat: add conversation history feature to AI assistant

- Add ConversationHistory dropdown component with list of past conversations
- Add useConversations hook for fetching and managing conversations via React Query
- Implement conversation switching with proper state management
- Fix bug where reopening panel showed new greeting instead of resuming conversation
- Fix bug where selecting from history caused conversation ID to revert
- Add server-side history context loading for resumed conversations
- Add Playwright E2E tests for conversation history feature
- Add logging for debugging conversation flow

Key changes:
- AssistantPanel: manages conversation state with localStorage persistence
- AssistantChat: header with [+] New Chat and [History] buttons
- Server: skips greeting for resumed conversations, loads history context on first message
- Fixed race condition in onConversationCreated callback
This commit is contained in:
liri
2026-01-16 21:47:58 +00:00
parent 91cc00a9d0
commit 7d761cb8d0
12 changed files with 1291 additions and 44 deletions

View File

@@ -269,18 +269,23 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
elif msg_type == "start":
# Get optional conversation_id to resume
conversation_id = message.get("conversation_id")
logger.info(f"Processing start message with conversation_id={conversation_id}")
try:
# Create a new session
logger.info(f"Creating session for {project_name}")
session = await create_session(
project_name,
project_dir,
conversation_id=conversation_id,
)
logger.info(f"Session created, starting...")
# Stream the initial greeting
async for chunk in session.start():
logger.info(f"Sending chunk: {chunk.get('type')}")
await websocket.send_json(chunk)
logger.info("Session start complete")
except Exception as e:
logger.exception(f"Error starting assistant session for {project_name}")
await websocket.send_json({

View File

@@ -23,6 +23,7 @@ from dotenv import load_dotenv
from .assistant_database import (
add_message,
create_conversation,
get_messages,
)
# Load environment variables from .env file if present
@@ -178,6 +179,7 @@ class AssistantChatSession:
self.client: Optional[ClaudeSDKClient] = None
self._client_entered: bool = False
self.created_at = datetime.now()
self._history_loaded: bool = False # Track if we've loaded history for resumed conversations
async def close(self) -> None:
"""Clean up resources and close the Claude client."""
@@ -195,10 +197,14 @@ class AssistantChatSession:
Initialize session with the Claude client.
Creates a new conversation if none exists, then sends an initial greeting.
For resumed conversations, skips the greeting since history is loaded from DB.
Yields message chunks as they stream in.
"""
# Track if this is a new conversation (for greeting decision)
is_new_conversation = self.conversation_id is None
# Create a new conversation if we don't have one
if self.conversation_id is None:
if is_new_conversation:
conv = create_conversation(self.project_dir, self.project_name)
self.conversation_id = conv.id
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
@@ -260,6 +266,7 @@ class AssistantChatSession:
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
try:
logger.info("Creating ClaudeSDKClient...")
self.client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
@@ -276,25 +283,35 @@ class AssistantChatSession:
env=sdk_env,
)
)
logger.info("Entering Claude client context...")
await self.client.__aenter__()
self._client_entered = True
logger.info("Claude client ready")
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?"
# Send initial greeting only for NEW conversations
# Resumed conversations already have history loaded from the database
if is_new_conversation:
# New conversations don't need history loading
self._history_loaded = True
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)
# Store the greeting in the database
add_message(self.project_dir, self.conversation_id, "assistant", greeting)
yield {"type": "text", "content": 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)}"}
else:
# For resumed conversations, history will be loaded on first message
# _history_loaded stays False so send_message() will include history
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]:
"""
@@ -321,8 +338,30 @@ class AssistantChatSession:
# Store user message in database
add_message(self.project_dir, self.conversation_id, "user", user_message)
# For resumed conversations, include history context in first message
message_to_send = user_message
if not self._history_loaded:
self._history_loaded = True
history = get_messages(self.project_dir, self.conversation_id)
# Exclude the message we just added (last one)
history = history[:-1] if history else []
if history:
# Format history as context for Claude
history_lines = ["[Previous conversation history for context:]"]
for msg in history:
role = "User" if msg["role"] == "user" else "Assistant"
content = msg["content"]
# Truncate very long messages
if len(content) > 500:
content = content[:500] + "..."
history_lines.append(f"{role}: {content}")
history_lines.append("[End of history. Continue the conversation:]")
history_lines.append(f"User: {user_message}")
message_to_send = "\n".join(history_lines)
logger.info(f"Loaded {len(history)} messages from conversation history")
try:
async for chunk in self._query_claude(user_message):
async for chunk in self._query_claude(message_to_send):
yield chunk
yield {"type": "response_done"}
except Exception as e: