diff --git a/.gitignore b/.gitignore index 0c478eaa..92fe0e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ coverage.xml .hypothesis/ .pytest_cache/ nosetests.xml +ui/playwright-report/ # mypy .mypy_cache/ @@ -142,3 +143,4 @@ Pipfile.lock .tmp/ .temp/ tmpclaude-*-cwd +ui/test-results/ diff --git a/server/routers/assistant_chat.py b/server/routers/assistant_chat.py index dae53b4a..32ba6f45 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,18 +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.debug(f"Processing start message with conversation_id={conversation_id}") try: # Create a new session + logger.debug(f"Creating session for {project_name}") session = await create_session( project_name, project_dir, conversation_id=conversation_id, ) + logger.debug("Session created, starting...") # Stream the initial greeting async for chunk in session.start(): + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"Sending chunk: {chunk.get('type')}") await websocket.send_json(chunk) + 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 a7f00ca1..54e3d125 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -23,6 +23,7 @@ from .assistant_database import ( add_message, create_conversation, + get_messages, ) # Load environment variables from .env file if present @@ -178,6 +179,7 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option 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 @@ 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. + 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 @@ async def start(self) -> AsyncGenerator[dict, None]: 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 @@ async def start(self) -> AsyncGenerator[dict, None]: 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,32 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: # 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 [] + # 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:]"] + 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: diff --git a/ui/e2e/conversation-history.spec.ts b/ui/e2e/conversation-history.spec.ts new file mode 100644 index 00000000..eca45256 --- /dev/null +++ b/ui/e2e/conversation-history.spec.ts @@ -0,0 +1,565 @@ +import { test, expect } from '@playwright/test' + +/** + * E2E tests for the Conversation History feature in the Assistant panel. + * + * Two test groups: + * 1. UI Tests - Only test UI elements, no API needed + * 2. Integration Tests - Test full flow with API (skipped if API unavailable) + * + * Run tests: + * cd ui && npm run test:e2e + * cd ui && npm run test:e2e:ui (interactive mode) + */ + +// ============================================================================= +// UI TESTS - No API required, just test UI elements +// ============================================================================= +test.describe('Assistant Panel UI', () => { + test.setTimeout(30000) + + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForSelector('button:has-text("Select Project")', { timeout: 10000 }) + }) + + async function selectProject(page: import('@playwright/test').Page) { + const projectSelector = page.locator('button:has-text("Select Project")') + if (await projectSelector.isVisible()) { + await projectSelector.click() + const projectItem = page.locator('.neo-dropdown-item').first() + const hasProject = await projectItem.isVisible().catch(() => false) + if (!hasProject) { + return false + } + await projectItem.click() + // Wait for dropdown to close (project selected) + await expect(projectSelector).not.toBeVisible({ timeout: 5000 }).catch(() => {}) + return true + } + return false + } + + async function waitForPanelOpen(page: import('@playwright/test').Page) { + await page.waitForFunction(() => { + const panel = document.querySelector('[aria-label="Project Assistant"]') + return panel && panel.getAttribute('aria-hidden') !== 'true' + }, { timeout: 5000 }) + } + + async function waitForPanelClosed(page: import('@playwright/test').Page) { + await page.waitForFunction(() => { + const panel = document.querySelector('[aria-label="Project Assistant"]') + return !panel || panel.getAttribute('aria-hidden') === 'true' + }, { timeout: 5000 }) + } + + // -------------------------------------------------------------------------- + // Panel open/close tests + // -------------------------------------------------------------------------- + test('Panel opens and closes with A key', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + const panel = page.locator('[aria-label="Project Assistant"]') + + // Panel should be closed initially + await expect(panel).toHaveAttribute('aria-hidden', 'true') + + // Press A to open + await page.keyboard.press('a') + await waitForPanelOpen(page) + await expect(panel).toHaveAttribute('aria-hidden', 'false') + + // Press A again to close + await page.keyboard.press('a') + await waitForPanelClosed(page) + await expect(panel).toHaveAttribute('aria-hidden', 'true') + }) + + test('Panel closes when clicking backdrop', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + const panel = page.locator('[aria-label="Project Assistant"]') + await expect(panel).toHaveAttribute('aria-hidden', 'false') + + // Click on the backdrop + const backdrop = page.locator('.fixed.inset-0.bg-black\\/20') + await backdrop.click() + + // Panel should close + await waitForPanelClosed(page) + await expect(panel).toHaveAttribute('aria-hidden', 'true') + }) + + test('Panel closes with X button', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + const panel = page.locator('[aria-label="Project Assistant"]') + await expect(panel).toHaveAttribute('aria-hidden', 'false') + + // Click X button (inside the panel dialog, not the floating button) + const closeButton = page.locator('[aria-label="Project Assistant"] button[title="Close Assistant (Press A)"]') + await closeButton.click() + + // Panel should close + await waitForPanelClosed(page) + await expect(panel).toHaveAttribute('aria-hidden', 'true') + }) + + // -------------------------------------------------------------------------- + // Header buttons tests + // -------------------------------------------------------------------------- + test('New chat and history buttons are visible and clickable', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Verify New Chat button + const newChatButton = page.locator('button[title="New conversation"]') + await expect(newChatButton).toBeVisible() + await expect(newChatButton).toBeEnabled() + + // Verify History button + const historyButton = page.locator('button[title="Conversation history"]') + await expect(historyButton).toBeVisible() + await expect(historyButton).toBeEnabled() + }) + + test('History dropdown opens and closes', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Click history button + const historyButton = page.locator('button[title="Conversation history"]') + await historyButton.click() + + // Dropdown should be visible + const historyDropdown = page.locator('h3:has-text("Conversation History")') + await expect(historyDropdown).toBeVisible({ timeout: 5000 }) + + // Dropdown should be inside the panel (not hidden by edge) + const dropdownBox = await page.locator('.neo-dropdown:has-text("Conversation History")').boundingBox() + const panelBox = await page.locator('[aria-label="Project Assistant"]').boundingBox() + + if (dropdownBox && panelBox) { + // Dropdown left edge should be >= panel left edge (not cut off) + expect(dropdownBox.x).toBeGreaterThanOrEqual(panelBox.x - 10) // small tolerance + } + + // Close dropdown by pressing Escape (more reliable than clicking backdrop) + await page.keyboard.press('Escape') + await expect(historyDropdown).not.toBeVisible({ timeout: 5000 }) + }) + + test('History dropdown shows empty state or conversations', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Click history button + const historyButton = page.locator('button[title="Conversation history"]') + await historyButton.click() + + // Should show either "No conversations yet" or a list of conversations + const dropdown = page.locator('.neo-dropdown:has-text("Conversation History")') + await expect(dropdown).toBeVisible({ timeout: 5000 }) + + // Check content - either empty state or conversation items + const emptyState = dropdown.locator('text=No conversations yet') + const conversationItems = dropdown.locator('.neo-dropdown-item') + + const hasEmpty = await emptyState.isVisible().catch(() => false) + const itemCount = await conversationItems.count() + + // Should have either empty state or some items + expect(hasEmpty || itemCount > 0).toBe(true) + console.log(`History shows: ${hasEmpty ? 'empty state' : `${itemCount} conversations`}`) + }) + + // -------------------------------------------------------------------------- + // Input area tests + // -------------------------------------------------------------------------- + test('Input textarea exists and is focusable', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Input should exist + const inputArea = page.locator('textarea[placeholder="Ask about the codebase..."]') + await expect(inputArea).toBeVisible() + + // Should be able to type in it (even if disabled, we can check it exists) + const placeholder = await inputArea.getAttribute('placeholder') + expect(placeholder).toBe('Ask about the codebase...') + }) + + test('Send button exists', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Send button should exist + const sendButton = page.locator('button[title="Send message"]') + await expect(sendButton).toBeVisible() + }) + + // -------------------------------------------------------------------------- + // Connection status tests + // -------------------------------------------------------------------------- + test('Connection status indicator exists', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Wait for any status to appear + await page.waitForFunction(() => { + const text = document.body.innerText + return text.includes('Connecting...') || text.includes('Connected') || text.includes('Disconnected') + }, { timeout: 10000 }) + + // One of the status indicators should be visible + const connecting = await page.locator('text=Connecting...').isVisible().catch(() => false) + const connected = await page.locator('text=Connected').isVisible().catch(() => false) + const disconnected = await page.locator('text=Disconnected').isVisible().catch(() => false) + + expect(connecting || connected || disconnected).toBe(true) + console.log(`Connection status: ${connected ? 'Connected' : disconnected ? 'Disconnected' : 'Connecting'}`) + }) + + // -------------------------------------------------------------------------- + // Panel header tests + // -------------------------------------------------------------------------- + test('Panel header shows project name', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + // Open panel + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Header should show "Project Assistant" + const header = page.locator('h2:has-text("Project Assistant")') + await expect(header).toBeVisible() + }) +}) + +// ============================================================================= +// INTEGRATION TESTS - Require API connection +// ============================================================================= +test.describe('Conversation History Integration', () => { + test.setTimeout(120000) + + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForSelector('button:has-text("Select Project")', { timeout: 10000 }) + }) + + async function selectProject(page: import('@playwright/test').Page) { + const projectSelector = page.locator('button:has-text("Select Project")') + if (await projectSelector.isVisible()) { + await projectSelector.click() + const projectItem = page.locator('.neo-dropdown-item').first() + const hasProject = await projectItem.isVisible().catch(() => false) + if (!hasProject) return false + await projectItem.click() + // Wait for dropdown to close (project selected) + await expect(projectSelector).not.toBeVisible({ timeout: 5000 }).catch(() => {}) + return true + } + return false + } + + async function waitForPanelOpen(page: import('@playwright/test').Page) { + await page.waitForFunction(() => { + const panel = document.querySelector('[aria-label="Project Assistant"]') + return panel && panel.getAttribute('aria-hidden') !== 'true' + }, { timeout: 5000 }) + } + + async function waitForPanelClosed(page: import('@playwright/test').Page) { + await page.waitForFunction(() => { + const panel = document.querySelector('[aria-label="Project Assistant"]') + return !panel || panel.getAttribute('aria-hidden') === 'true' + }, { timeout: 5000 }) + } + + async function waitForAssistantReady(page: import('@playwright/test').Page): Promise { + try { + await page.waitForSelector('text=Connected', { timeout: 15000 }) + const inputArea = page.locator('textarea[placeholder="Ask about the codebase..."]') + await expect(inputArea).toBeEnabled({ timeout: 30000 }) + return true + } catch { + console.log('Assistant not available - API may not be configured') + return false + } + } + + async function sendMessageAndWaitForResponse(page: import('@playwright/test').Page, message: string) { + const inputArea = page.locator('textarea[placeholder="Ask about the codebase..."]') + await inputArea.fill(message) + await inputArea.press('Enter') + await expect(page.locator(`text=${message}`).first()).toBeVisible({ timeout: 5000 }) + await page.waitForSelector('text=Thinking...', { timeout: 10000 }).catch(() => {}) + await expect(inputArea).toBeEnabled({ timeout: 60000 }) + // Wait for any streaming to complete (input enabled means response done) + } + + // -------------------------------------------------------------------------- + // Full flow test + // -------------------------------------------------------------------------- + test('Full conversation flow: create, persist, switch conversations', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + await page.keyboard.press('a') + await waitForPanelOpen(page) + + if (!await waitForAssistantReady(page)) { + test.skip(true, 'Assistant API not available') + return + } + + // STEP 1: Send first message + console.log('STEP 1: Ask 1+1') + await sendMessageAndWaitForResponse(page, 'how much is 1+1') + await expect(page.locator('.flex-1.overflow-y-auto')).toContainText('2', { timeout: 5000 }) + + // Count greeting messages before closing + const greetingSelector = 'text=Hello! I\'m your project assistant' + const greetingCountBefore = await page.locator(greetingSelector).count() + console.log(`Greeting count before close: ${greetingCountBefore}`) + + // STEP 2: Close and reopen - should see same conversation WITHOUT new greeting + console.log('STEP 2: Close and reopen') + const closeButton = page.locator('[aria-label="Project Assistant"] button[title="Close Assistant (Press A)"]') + await closeButton.click() + await waitForPanelClosed(page) + + await page.keyboard.press('a') + await waitForPanelOpen(page) + + // Verify our question is still visible (conversation resumed) + await expect(page.locator('text=how much is 1+1').first()).toBeVisible({ timeout: 10000 }) + + // CRITICAL: Verify NO new greeting was added (bug fix verification) + const greetingCountAfter = await page.locator(greetingSelector).count() + console.log(`Greeting count after reopen: ${greetingCountAfter}`) + expect(greetingCountAfter).toBe(greetingCountBefore) + + // STEP 3: Start new chat + console.log('STEP 3: New chat') + const newChatButton = page.locator('button[title="New conversation"]') + await newChatButton.click() + + if (!await waitForAssistantReady(page)) { + test.skip(true, 'Assistant API not available') + return + } + + await expect(page.locator('text=how much is 1+1')).not.toBeVisible({ timeout: 5000 }) + + // STEP 4: Send second message in new chat + console.log('STEP 4: Ask 2+2') + await sendMessageAndWaitForResponse(page, 'how much is 2+2') + await expect(page.locator('.flex-1.overflow-y-auto')).toContainText('4', { timeout: 5000 }) + + // STEP 5: Check history has both conversations + console.log('STEP 5: Check history') + const historyButton = page.locator('button[title="Conversation history"]') + await historyButton.click() + await expect(page.locator('h3:has-text("Conversation History")')).toBeVisible() + + const conversationItems = page.locator('.neo-dropdown:has-text("Conversation History") .neo-dropdown-item') + const count = await conversationItems.count() + console.log(`Found ${count} conversations`) + expect(count).toBeGreaterThanOrEqual(2) + + // STEP 6: Switch to first conversation + console.log('STEP 6: Switch conversation') + await conversationItems.nth(1).click() + // 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() + + console.log('All steps completed!') + }) + + // -------------------------------------------------------------------------- + // Delete conversation test + // -------------------------------------------------------------------------- + test('Delete conversation from history', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + await page.keyboard.press('a') + await waitForPanelOpen(page) + + if (!await waitForAssistantReady(page)) { + test.skip(true, 'Assistant API not available') + return + } + + // Create a conversation + await sendMessageAndWaitForResponse(page, `test delete ${Date.now()}`) + + // Open history and get count + const historyButton = page.locator('button[title="Conversation history"]') + await historyButton.click() + await expect(page.locator('h3:has-text("Conversation History")')).toBeVisible() + + const conversationItems = page.locator('.neo-dropdown:has-text("Conversation History") .neo-dropdown-item') + const countBefore = await conversationItems.count() + + // Delete first conversation + const deleteButton = page.locator('.neo-dropdown:has-text("Conversation History") button[title="Delete conversation"]').first() + await deleteButton.click() + + // Confirm + const confirmButton = page.locator('button:has-text("Delete")').last() + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + // Wait for confirmation dialog to close + await expect(confirmButton).not.toBeVisible({ timeout: 5000 }) + + // Verify count decreased + await historyButton.click() + const countAfter = await conversationItems.count() + expect(countAfter).toBeLessThan(countBefore) + }) + + // -------------------------------------------------------------------------- + // Send button state test + // -------------------------------------------------------------------------- + test('Send button disabled when empty, enabled with text', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + await page.keyboard.press('a') + await waitForPanelOpen(page) + + if (!await waitForAssistantReady(page)) { + test.skip(true, 'Assistant API not available') + return + } + + const inputArea = page.locator('textarea[placeholder="Ask about the codebase..."]') + const sendButton = page.locator('button[title="Send message"]') + + // Empty = disabled + await inputArea.fill('') + await expect(sendButton).toBeDisabled() + + // With text = enabled + await inputArea.fill('test') + await expect(sendButton).toBeEnabled() + + // Empty again = disabled + await inputArea.fill('') + await expect(sendButton).toBeDisabled() + }) + + // -------------------------------------------------------------------------- + // Shift+Enter test + // -------------------------------------------------------------------------- + test('Shift+Enter adds newline, Enter sends', async ({ page }) => { + const hasProject = await selectProject(page) + if (!hasProject) { + test.skip(true, 'No projects available') + return + } + + await page.keyboard.press('a') + await waitForPanelOpen(page) + + if (!await waitForAssistantReady(page)) { + test.skip(true, 'Assistant API not available') + return + } + + const inputArea = page.locator('textarea[placeholder="Ask about the codebase..."]') + + // Type and add newline + await inputArea.fill('Line 1') + await inputArea.press('Shift+Enter') + await inputArea.pressSequentially('Line 2') + + const value = await inputArea.inputValue() + expect(value).toContain('Line 1') + expect(value).toContain('Line 2') + + // Enter sends + await inputArea.press('Enter') + await expect(page.locator('text=Line 1').first()).toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/ui/package-lock.json b/ui/package-lock.json index 6135f476..b38823d5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.0.0-beta.4", "@types/canvas-confetti": "^1.9.0", "@types/react": "^18.3.12", @@ -1008,6 +1009,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2172,6 +2188,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -4028,6 +4104,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/ui/package.json b/ui/package.json index 560f821a..bc912fa1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.2", @@ -25,6 +27,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.0.0-beta.4", "@types/canvas-confetti": "^1.9.0", "@types/react": "^18.3.12", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..f6037830 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index 422a40d3..b2a721e7 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -3,22 +3,41 @@ * * Main chat interface for the project assistant. * Displays messages and handles user input. + * Supports conversation history with resume functionality. */ -import { useState, useRef, useEffect, useCallback } from 'react' -import { Send, Loader2, Wifi, WifiOff } from 'lucide-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 } from './ChatMessage' +import { ChatMessage as ChatMessageComponent } from './ChatMessage' +import { ConversationHistory } from './ConversationHistory' +import type { ChatMessage } from '../lib/types' interface AssistantChatProps { projectName: string + conversationId?: number | null + initialMessages?: ChatMessage[] + isLoadingConversation?: boolean + onNewChat?: () => void + onSelectConversation?: (id: number) => void + onConversationCreated?: (id: number) => void } -export function AssistantChat({ projectName }: AssistantChatProps) { +export function AssistantChat({ + projectName, + conversationId, + initialMessages, + isLoadingConversation, + onNewChat, + onSelectConversation, + onConversationCreated, +}: AssistantChatProps) { const [inputValue, setInputValue] = useState('') + const [showHistory, setShowHistory] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) const hasStartedRef = useRef(false) + const lastConversationIdRef = useRef(undefined) // Memoize the error handler to prevent infinite re-renders const handleError = useCallback((error: string) => { @@ -29,25 +48,91 @@ export function AssistantChat({ projectName }: AssistantChatProps) { messages, isLoading, connectionStatus, + conversationId: activeConversationId, start, sendMessage, + clearMessages, } = useAssistantChat({ projectName, onError: handleError, }) + // Notify parent when a NEW conversation is created (not when switching to existing) + // Track activeConversationId to fire callback only once when it transitions from null to a value + const previousActiveConversationIdRef = useRef(activeConversationId) + useEffect(() => { + const hadNoConversation = previousActiveConversationIdRef.current === null + const nowHasConversation = activeConversationId !== null + + if (hadNoConversation && nowHasConversation && onConversationCreated) { + onConversationCreated(activeConversationId) + } + + previousActiveConversationIdRef.current = activeConversationId + }, [activeConversationId, onConversationCreated]) + // Auto-scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) - // Start the chat session when component mounts (only once) + // Start or resume the chat session when component mounts or conversationId changes useEffect(() => { - if (!hasStartedRef.current) { - hasStartedRef.current = true - start() + console.log('[AssistantChat] useEffect running:', { + conversationId, + isLoadingConversation, + lastRef: lastConversationIdRef.current, + hasStarted: hasStartedRef.current + }) + + // Skip if we're loading conversation details + if (isLoadingConversation) { + console.log('[AssistantChat] Skipping - loading conversation') + return + } + + // Only start if conversationId has actually changed + if (lastConversationIdRef.current === conversationId && hasStartedRef.current) { + console.log('[AssistantChat] Skipping - same conversationId') + return + } + + // Check if we're switching to a different conversation (not initial mount) + const isSwitching = lastConversationIdRef.current !== undefined && + lastConversationIdRef.current !== conversationId + + console.log('[AssistantChat] Processing conversation change:', { + from: lastConversationIdRef.current, + to: conversationId, + isSwitching + }) + + lastConversationIdRef.current = conversationId + hasStartedRef.current = true + + // Clear existing messages when switching conversations + if (isSwitching) { + console.log('[AssistantChat] Clearing messages for conversation switch') + clearMessages() } - }, [start]) + + // Start the session with the conversation ID (or null for new) + console.log('[AssistantChat] Starting session with conversationId:', conversationId) + start(conversationId) + }, [conversationId, isLoadingConversation, start, clearMessages]) + + // Handle starting a new chat + const handleNewChat = useCallback(() => { + clearMessages() + onNewChat?.() + }, [clearMessages, onNewChat]) + + // Handle selecting a conversation from history + const handleSelectConversation = useCallback((id: number) => { + console.log('[AssistantChat] handleSelectConversation called with id:', id) + setShowHistory(false) + onSelectConversation?.(id) + }, [onSelectConversation]) // Focus input when not loading useEffect(() => { @@ -58,7 +143,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) { const handleSend = () => { const content = inputValue.trim() - if (!content || isLoading) return + if (!content || isLoading || isLoadingConversation) return sendMessage(content) setInputValue('') @@ -71,31 +156,99 @@ export function AssistantChat({ projectName }: AssistantChatProps) { } } + // Combine initial messages (from resumed conversation) with live messages + // 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 (
- {/* Connection status indicator */} -
- {connectionStatus === 'connected' ? ( - <> - - Connected - - ) : connectionStatus === 'connecting' ? ( - <> - - Connecting... - - ) : ( - <> - - Disconnected - - )} + {/* Header with actions and connection status */} +
+ {/* Action buttons */} +
+ + + + {/* History dropdown */} + setShowHistory(false)} + onSelectConversation={handleSelectConversation} + /> +
+ + {/* Connection status */} +
+ {connectionStatus === 'connected' ? ( + <> + + Connected + + ) : connectionStatus === 'connecting' ? ( + <> + + Connecting... + + ) : ( + <> + + Disconnected + + )} +
{/* Messages area */}
- {messages.length === 0 ? ( + {isLoadingConversation ? ( +
+
+ + Loading conversation... +
+
+ ) : displayMessages.length === 0 ? (
{isLoading ? (
@@ -108,8 +261,8 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
) : (
- {messages.map((message) => ( - + {displayMessages.map((message) => ( + ))}
@@ -117,7 +270,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
{/* Loading indicator */} - {isLoading && messages.length > 0 && ( + {isLoading && displayMessages.length > 0 && (
@@ -139,7 +292,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) { onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Ask about the codebase..." - disabled={isLoading || connectionStatus !== 'connected'} + disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'} className=" flex-1 neo-input @@ -152,7 +305,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) { />
diff --git a/ui/src/components/ConversationHistory.tsx b/ui/src/components/ConversationHistory.tsx new file mode 100644 index 00000000..442b28fc --- /dev/null +++ b/ui/src/components/ConversationHistory.tsx @@ -0,0 +1,196 @@ +/** + * Conversation History Dropdown Component + * + * Displays a list of past conversations for the assistant. + * Allows selecting a conversation to resume or deleting old conversations. + */ + +import { useState, useEffect } from 'react' +import { MessageSquare, Trash2, Loader2 } from 'lucide-react' +import { useConversations, useDeleteConversation } from '../hooks/useConversations' +import { ConfirmDialog } from './ConfirmDialog' +import type { AssistantConversation } from '../lib/types' + +interface ConversationHistoryProps { + projectName: string + currentConversationId: number | null + isOpen: boolean + onClose: () => void + onSelectConversation: (conversationId: number) => void +} + +/** + * Format a relative time string from an ISO date + */ +function formatRelativeTime(dateString: string | null): string { + if (!dateString) return '' + + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return 'just now' + if (diffMinutes < 60) return `${diffMinutes}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return 'yesterday' + if (diffDays < 7) return `${diffDays}d ago` + + return date.toLocaleDateString() +} + +export function ConversationHistory({ + projectName, + currentConversationId, + isOpen, + onClose, + onSelectConversation, +}: ConversationHistoryProps) { + const [conversationToDelete, setConversationToDelete] = useState(null) + + const { data: conversations, isLoading } = useConversations(projectName) + const deleteConversation = useDeleteConversation(projectName) + + const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => { + e.stopPropagation() + setConversationToDelete(conversation) + } + + const handleConfirmDelete = async () => { + if (!conversationToDelete) return + + try { + await deleteConversation.mutateAsync(conversationToDelete.id) + setConversationToDelete(null) + } catch (error) { + console.error('Failed to delete conversation:', error) + setConversationToDelete(null) + } + } + + const handleCancelDelete = () => { + setConversationToDelete(null) + } + + const handleSelectConversation = (conversationId: number) => { + onSelectConversation(conversationId) + onClose() + } + + // Handle Escape key to close dropdown + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + if (!isOpen) return null + + return ( + <> + {/* Backdrop */} +
+ + {/* Dropdown */} +
+ {/* Header */} +
+

Conversation History

+
+ + {/* Content */} + {isLoading ? ( +
+ +
+ ) : !conversations || conversations.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {conversations.map((conversation) => { + const isCurrent = conversation.id === currentConversationId + + return ( +
+ + +
+ ) + })} +
+ )} +
+ + {/* Delete Confirmation Dialog */} + + + ) +} diff --git a/ui/src/hooks/useAssistantChat.ts b/ui/src/hooks/useAssistantChat.ts index 4888c7d8..b8fedff4 100755 --- a/ui/src/hooks/useAssistantChat.ts +++ b/ui/src/hooks/useAssistantChat.ts @@ -120,6 +120,9 @@ export function useAssistantChat({ ws.onmessage = (event) => { try { const data = JSON.parse(event.data) as AssistantChatServerMessage; + if (import.meta.env.DEV) { + console.debug('[useAssistantChat] Received WebSocket message:', data.type, data); + } switch (data.type) { case "text": { @@ -277,6 +280,9 @@ export function useAssistantChat({ payload.conversation_id = existingConversationId; setConversationId(existingConversationId); } + if (import.meta.env.DEV) { + console.debug('[useAssistantChat] Sending start message:', payload); + } wsRef.current.send(JSON.stringify(payload)); } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { checkAndSendTimeoutRef.current = window.setTimeout(checkAndSend, 100); @@ -336,7 +342,7 @@ export function useAssistantChat({ const clearMessages = useCallback(() => { setMessages([]); - setConversationId(null); + // Don't reset conversationId here - it will be set by start() when switching }, []); return { diff --git a/ui/src/hooks/useConversations.ts b/ui/src/hooks/useConversations.ts new file mode 100644 index 00000000..908b22da --- /dev/null +++ b/ui/src/hooks/useConversations.ts @@ -0,0 +1,48 @@ +/** + * React Query hooks for assistant conversation management + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import * as api from '../lib/api' + +/** + * List all conversations for a project + */ +export function useConversations(projectName: string | null) { + return useQuery({ + queryKey: ['conversations', projectName], + queryFn: () => api.listAssistantConversations(projectName!), + enabled: !!projectName, + staleTime: 30000, // Cache for 30 seconds + }) +} + +/** + * Get a single conversation with all its messages + */ +export function useConversation(projectName: string | null, conversationId: number | null) { + return useQuery({ + queryKey: ['conversation', projectName, conversationId], + queryFn: () => api.getAssistantConversation(projectName!, conversationId!), + enabled: !!projectName && !!conversationId, + staleTime: 30_000, // Cache for 30 seconds + }) +} + +/** + * Delete a conversation + */ +export function useDeleteConversation(projectName: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (conversationId: number) => + api.deleteAssistantConversation(projectName, conversationId), + onSuccess: (_, deletedId) => { + // Invalidate conversations list + queryClient.invalidateQueries({ queryKey: ['conversations', projectName] }) + // Remove the specific conversation from cache + queryClient.removeQueries({ queryKey: ['conversation', projectName, deletedId] }) + }, + }) +}