diff --git a/.gitignore b/.gitignore index f8c1035..92fe0e9 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,7 @@ coverage.xml .hypothesis/ .pytest_cache/ nosetests.xml -./ui/playwright-report +ui/playwright-report/ # mypy .mypy_cache/ @@ -143,4 +143,4 @@ Pipfile.lock .tmp/ .temp/ tmpclaude-*-cwd -./ui/test-results +ui/test-results/ diff --git a/server/routers/assistant_chat.py b/server/routers/assistant_chat.py index 3c71932..32ba6f4 100644 --- a/server/routers/assistant_chat.py +++ b/server/routers/assistant_chat.py @@ -260,7 +260,7 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str): data = await websocket.receive_text() message = json.loads(data) msg_type = message.get("type") - logger.info(f"Assistant received message type: {msg_type}") + logger.debug(f"Assistant received message type: {msg_type}") if msg_type == "ping": await websocket.send_json({"type": "pong"}) @@ -269,23 +269,24 @@ 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}") + logger.debug(f"Processing start message with conversation_id={conversation_id}") try: # Create a new session - logger.info(f"Creating session for {project_name}") + logger.debug(f"Creating session for {project_name}") session = await create_session( project_name, project_dir, conversation_id=conversation_id, ) - logger.info(f"Session created, starting...") + logger.debug("Session created, starting...") # Stream the initial greeting async for chunk in session.start(): - logger.info(f"Sending chunk: {chunk.get('type')}") + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"Sending chunk: {chunk.get('type')}") await websocket.send_json(chunk) - logger.info("Session start complete") + logger.debug("Session start complete") except Exception as e: logger.exception(f"Error starting assistant session for {project_name}") await websocket.send_json({ diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 9dbe821..54e3d12 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -345,6 +345,8 @@ class AssistantChatSession: history = get_messages(self.project_dir, self.conversation_id) # Exclude the message we just added (last one) history = history[:-1] if history else [] + # Cap history to last 35 messages to prevent context overload + history = history[-35:] if len(history) > 35 else history if history: # Format history as context for Claude history_lines = ["[Previous conversation history for context:]"] diff --git a/ui/e2e/conversation-history.spec.ts b/ui/e2e/conversation-history.spec.ts index 3717551..eca4525 100644 --- a/ui/e2e/conversation-history.spec.ts +++ b/ui/e2e/conversation-history.spec.ts @@ -33,7 +33,8 @@ test.describe('Assistant Panel UI', () => { return false } await projectItem.click() - await page.waitForTimeout(500) + // Wait for dropdown to close (project selected) + await expect(projectSelector).not.toBeVisible({ timeout: 5000 }).catch(() => {}) return true } return false @@ -321,7 +322,8 @@ test.describe('Conversation History Integration', () => { const hasProject = await projectItem.isVisible().catch(() => false) if (!hasProject) return false await projectItem.click() - await page.waitForTimeout(500) + // Wait for dropdown to close (project selected) + await expect(projectSelector).not.toBeVisible({ timeout: 5000 }).catch(() => {}) return true } return false @@ -360,7 +362,7 @@ test.describe('Conversation History Integration', () => { await expect(page.locator(`text=${message}`).first()).toBeVisible({ timeout: 5000 }) await page.waitForSelector('text=Thinking...', { timeout: 10000 }).catch(() => {}) await expect(inputArea).toBeEnabled({ timeout: 60000 }) - await page.waitForTimeout(500) + // Wait for any streaming to complete (input enabled means response done) } // -------------------------------------------------------------------------- @@ -399,7 +401,6 @@ test.describe('Conversation History Integration', () => { await page.keyboard.press('a') await waitForPanelOpen(page) - await page.waitForTimeout(2000) // Verify our question is still visible (conversation resumed) await expect(page.locator('text=how much is 1+1').first()).toBeVisible({ timeout: 10000 }) @@ -413,7 +414,6 @@ test.describe('Conversation History Integration', () => { console.log('STEP 3: New chat') const newChatButton = page.locator('button[title="New conversation"]') await newChatButton.click() - await page.waitForTimeout(500) if (!await waitForAssistantReady(page)) { test.skip(true, 'Assistant API not available') @@ -441,7 +441,7 @@ test.describe('Conversation History Integration', () => { // STEP 6: Switch to first conversation console.log('STEP 6: Switch conversation') await conversationItems.nth(1).click() - await page.waitForTimeout(2000) + // Wait for conversation to load by checking for the expected message await expect(page.locator('text=how much is 1+1').first()).toBeVisible({ timeout: 10000 }) await expect(page.locator('text=how much is 2+2')).not.toBeVisible() @@ -485,7 +485,9 @@ test.describe('Conversation History Integration', () => { const confirmButton = page.locator('button:has-text("Delete")').last() await expect(confirmButton).toBeVisible() await confirmButton.click() - await page.waitForTimeout(1000) + + // Wait for confirmation dialog to close + await expect(confirmButton).not.toBeVisible({ timeout: 5000 }) // Verify count decreased await historyButton.click() diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index 0eb3aa6..b2a721e 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -6,7 +6,7 @@ * Supports conversation history with resume functionality. */ -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react' import { useAssistantChat } from '../hooks/useAssistantChat' import { ChatMessage as ChatMessageComponent } from './ChatMessage' @@ -58,21 +58,18 @@ export function AssistantChat({ }) // Notify parent when a NEW conversation is created (not when switching to existing) - // This should only fire when conversationId was null/undefined and a new one was created - const previousConversationIdRef = useRef(conversationId) + // Track activeConversationId to fire callback only once when it transitions from null to a value + const previousActiveConversationIdRef = useRef(activeConversationId) useEffect(() => { - // Only notify if we had NO conversation (null/undefined) and now we have one - // This prevents the bug where switching conversations would trigger this - const hadNoConversation = previousConversationIdRef.current === null || previousConversationIdRef.current === undefined - const nowHasConversation = activeConversationId !== null && activeConversationId !== undefined + const hadNoConversation = previousActiveConversationIdRef.current === null + const nowHasConversation = activeConversationId !== null if (hadNoConversation && nowHasConversation && onConversationCreated) { - console.log('[AssistantChat] New conversation created:', activeConversationId) onConversationCreated(activeConversationId) } - previousConversationIdRef.current = conversationId - }, [activeConversationId, conversationId, onConversationCreated]) + previousActiveConversationIdRef.current = activeConversationId + }, [activeConversationId, onConversationCreated]) // Auto-scroll to bottom on new messages useEffect(() => { @@ -146,7 +143,7 @@ export function AssistantChat({ const handleSend = () => { const content = inputValue.trim() - if (!content || isLoading) return + if (!content || isLoading || isLoadingConversation) return sendMessage(content) setInputValue('') @@ -160,23 +157,30 @@ export function AssistantChat({ } // Combine initial messages (from resumed conversation) with live messages - // Show initialMessages when: - // 1. We have initialMessages from the API - // 2. AND either messages is empty OR we haven't processed this conversation yet - // This prevents showing old conversation messages while switching - const isConversationSynced = lastConversationIdRef.current === conversationId && !isLoadingConversation - const displayMessages = initialMessages && (messages.length === 0 || !isConversationSynced) - ? initialMessages - : messages - console.log('[AssistantChat] displayMessages decision:', { - conversationId, - lastRef: lastConversationIdRef.current, - isConversationSynced, - initialMessagesCount: initialMessages?.length ?? 0, - messagesCount: messages.length, - displayMessagesCount: displayMessages.length, - showingInitial: displayMessages === initialMessages - }) + // Merge both arrays with deduplication by message ID to prevent history loss + const displayMessages = useMemo(() => { + const isConversationSynced = lastConversationIdRef.current === conversationId && !isLoadingConversation + + // If not synced yet, show only initialMessages (or empty) + if (!isConversationSynced) { + return initialMessages ?? [] + } + + // If no initial messages, just show live messages + if (!initialMessages || initialMessages.length === 0) { + return messages + } + + // Merge both arrays, deduplicating by ID (live messages take precedence) + const messageMap = new Map() + for (const msg of initialMessages) { + messageMap.set(msg.id, msg) + } + for (const msg of messages) { + messageMap.set(msg.id, msg) + } + return Array.from(messageMap.values()) + }, [initialMessages, messages, conversationId, isLoadingConversation]) return (
@@ -288,7 +292,7 @@ export function AssistantChat({ onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Ask about the codebase..." - disabled={isLoading || connectionStatus !== 'connected'} + disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'} className=" flex-1 neo-input @@ -301,7 +305,7 @@ export function AssistantChat({ />