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

2
.gitignore vendored
View File

@@ -64,6 +64,7 @@ coverage.xml
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
nosetests.xml nosetests.xml
./ui/playwright-report
# mypy # mypy
.mypy_cache/ .mypy_cache/
@@ -142,3 +143,4 @@ Pipfile.lock
.tmp/ .tmp/
.temp/ .temp/
tmpclaude-*-cwd tmpclaude-*-cwd
./ui/test-results

View File

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

View File

@@ -23,6 +23,7 @@ from dotenv import load_dotenv
from .assistant_database import ( from .assistant_database import (
add_message, add_message,
create_conversation, create_conversation,
get_messages,
) )
# Load environment variables from .env file if present # Load environment variables from .env file if present
@@ -178,6 +179,7 @@ class AssistantChatSession:
self.client: Optional[ClaudeSDKClient] = None self.client: Optional[ClaudeSDKClient] = None
self._client_entered: bool = False self._client_entered: bool = False
self.created_at = datetime.now() self.created_at = datetime.now()
self._history_loaded: bool = False # Track if we've loaded history for resumed conversations
async def close(self) -> None: async def close(self) -> None:
"""Clean up resources and close the Claude client.""" """Clean up resources and close the Claude client."""
@@ -195,10 +197,14 @@ class AssistantChatSession:
Initialize session with the Claude client. Initialize session with the Claude client.
Creates a new conversation if none exists, then sends an initial greeting. 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. 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 # 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) conv = create_conversation(self.project_dir, self.project_name)
self.conversation_id = conv.id self.conversation_id = conv.id
yield {"type": "conversation_created", "conversation_id": self.conversation_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") model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
try: try:
logger.info("Creating ClaudeSDKClient...")
self.client = ClaudeSDKClient( self.client = ClaudeSDKClient(
options=ClaudeAgentOptions( options=ClaudeAgentOptions(
model=model, model=model,
@@ -276,25 +283,35 @@ class AssistantChatSession:
env=sdk_env, env=sdk_env,
) )
) )
logger.info("Entering Claude client context...")
await self.client.__aenter__() await self.client.__aenter__()
self._client_entered = True self._client_entered = True
logger.info("Claude client ready")
except Exception as e: except Exception as e:
logger.exception("Failed to create Claude client") logger.exception("Failed to create Claude client")
yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
return return
# Send initial greeting # Send initial greeting only for NEW conversations
try: # Resumed conversations already have history loaded from the database
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?" 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 # Store the greeting in the database
add_message(self.project_dir, self.conversation_id, "assistant", greeting) 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"} 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]: async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
""" """
@@ -321,8 +338,30 @@ class AssistantChatSession:
# Store user message in database # Store user message in database
add_message(self.project_dir, self.conversation_id, "user", user_message) 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: try:
async for chunk in self._query_claude(user_message): async for chunk in self._query_claude(message_to_send):
yield chunk yield chunk
yield {"type": "response_done"} yield {"type": "response_done"}
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,563 @@
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()
await page.waitForTimeout(500)
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()
await page.waitForTimeout(500)
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<boolean> {
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 })
await page.waitForTimeout(500)
}
// --------------------------------------------------------------------------
// 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)
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 })
// 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()
await page.waitForTimeout(500)
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()
await page.waitForTimeout(2000)
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()
await page.waitForTimeout(1000)
// 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 })
})
})

120
ui/package-lock.json generated
View File

@@ -23,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.0.0-beta.4", "@tailwindcss/vite": "^4.0.0-beta.4",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
@@ -1008,6 +1009,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@radix-ui/primitive": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -2172,6 +2188,66 @@
"node": ">=14.0.0" "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": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", "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" "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": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
@@ -25,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.0.0-beta.4", "@tailwindcss/vite": "^4.0.0-beta.4",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",

25
ui/playwright.config.ts Normal file
View File

@@ -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,
},
})

View File

@@ -3,22 +3,41 @@
* *
* Main chat interface for the project assistant. * Main chat interface for the project assistant.
* Displays messages and handles user input. * Displays messages and handles user input.
* Supports conversation history with resume functionality.
*/ */
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Loader2, Wifi, WifiOff } from 'lucide-react' import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react'
import { useAssistantChat } from '../hooks/useAssistantChat' 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 { interface AssistantChatProps {
projectName: string 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 [inputValue, setInputValue] = useState('')
const [showHistory, setShowHistory] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const hasStartedRef = useRef(false) const hasStartedRef = useRef(false)
const lastConversationIdRef = useRef<number | null | undefined>(undefined)
// Memoize the error handler to prevent infinite re-renders // Memoize the error handler to prevent infinite re-renders
const handleError = useCallback((error: string) => { const handleError = useCallback((error: string) => {
@@ -29,25 +48,94 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
messages, messages,
isLoading, isLoading,
connectionStatus, connectionStatus,
conversationId: activeConversationId,
start, start,
sendMessage, sendMessage,
clearMessages,
} = useAssistantChat({ } = useAssistantChat({
projectName, projectName,
onError: handleError, onError: handleError,
}) })
// 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<number | null | undefined>(conversationId)
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
if (hadNoConversation && nowHasConversation && onConversationCreated) {
console.log('[AssistantChat] New conversation created:', activeConversationId)
onConversationCreated(activeConversationId)
}
previousConversationIdRef.current = conversationId
}, [activeConversationId, conversationId, onConversationCreated])
// Auto-scroll to bottom on new messages // Auto-scroll to bottom on new messages
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [messages])
// Start the chat session when component mounts (only once) // Start or resume the chat session when component mounts or conversationId changes
useEffect(() => { useEffect(() => {
if (!hasStartedRef.current) { console.log('[AssistantChat] useEffect running:', {
hasStartedRef.current = true conversationId,
start() isLoadingConversation,
lastRef: lastConversationIdRef.current,
hasStarted: hasStartedRef.current
})
// Skip if we're loading conversation details
if (isLoadingConversation) {
console.log('[AssistantChat] Skipping - loading conversation')
return
} }
}, [start])
// 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 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 // Focus input when not loading
useEffect(() => { useEffect(() => {
@@ -71,31 +159,92 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
} }
} }
// 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
})
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Connection status indicator */} {/* Header with actions and connection status */}
<div className="flex items-center gap-2 px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <div className="flex items-center justify-between px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
{connectionStatus === 'connected' ? ( {/* Action buttons */}
<> <div className="flex items-center gap-1 relative">
<Wifi size={14} className="text-[var(--color-neo-done)]" /> <button
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connected</span> onClick={handleNewChat}
</> className="neo-btn neo-btn-ghost p-1.5 text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)]"
) : connectionStatus === 'connecting' ? ( title="New conversation"
<> disabled={isLoading}
<Loader2 size={14} className="text-[var(--color-neo-progress)] animate-spin" /> >
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connecting...</span> <Plus size={16} />
</> </button>
) : ( <button
<> onClick={() => setShowHistory(!showHistory)}
<WifiOff size={14} className="text-[var(--color-neo-danger)]" /> className={`neo-btn neo-btn-ghost p-1.5 ${
<span className="text-xs text-[var(--color-neo-text-secondary)]">Disconnected</span> showHistory
</> ? 'text-[var(--color-neo-text)] bg-[var(--color-neo-pending)]'
)} : 'text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)]'
}`}
title="Conversation history"
>
<History size={16} />
</button>
{/* History dropdown */}
<ConversationHistory
projectName={projectName}
currentConversationId={conversationId ?? activeConversationId}
isOpen={showHistory}
onClose={() => setShowHistory(false)}
onSelectConversation={handleSelectConversation}
/>
</div>
{/* Connection status */}
<div className="flex items-center gap-2">
{connectionStatus === 'connected' ? (
<>
<Wifi size={14} className="text-[var(--color-neo-done)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connected</span>
</>
) : connectionStatus === 'connecting' ? (
<>
<Loader2 size={14} className="text-[var(--color-neo-progress)] animate-spin" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connecting...</span>
</>
) : (
<>
<WifiOff size={14} className="text-[var(--color-neo-danger)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Disconnected</span>
</>
)}
</div>
</div> </div>
{/* Messages area */} {/* Messages area */}
<div className="flex-1 overflow-y-auto bg-[var(--color-neo-bg)]"> <div className="flex-1 overflow-y-auto bg-[var(--color-neo-bg)]">
{messages.length === 0 ? ( {isLoadingConversation ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-secondary)] text-sm">
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" />
<span>Loading conversation...</span>
</div>
</div>
) : displayMessages.length === 0 ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-secondary)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-neo-text-secondary)] text-sm">
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -108,8 +257,8 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
</div> </div>
) : ( ) : (
<div className="py-4"> <div className="py-4">
{messages.map((message) => ( {displayMessages.map((message) => (
<ChatMessage key={message.id} message={message} /> <ChatMessageComponent key={message.id} message={message} />
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -117,7 +266,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
</div> </div>
{/* Loading indicator */} {/* Loading indicator */}
{isLoading && messages.length > 0 && ( {isLoading && displayMessages.length > 0 && (
<div className="px-4 py-2 border-t-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <div className="px-4 py-2 border-t-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
<div className="flex items-center gap-2 text-[var(--color-neo-text-secondary)] text-sm"> <div className="flex items-center gap-2 text-[var(--color-neo-text-secondary)] text-sm">
<div className="flex gap-1"> <div className="flex gap-1">

View File

@@ -3,10 +3,14 @@
* *
* Slide-in panel container for the project assistant chat. * Slide-in panel container for the project assistant chat.
* Slides in from the right side of the screen. * Slides in from the right side of the screen.
* Manages conversation state with localStorage persistence.
*/ */
import { useState, useEffect, useCallback } from 'react'
import { X, Bot } from 'lucide-react' import { X, Bot } from 'lucide-react'
import { AssistantChat } from './AssistantChat' import { AssistantChat } from './AssistantChat'
import { useConversation } from '../hooks/useConversations'
import type { ChatMessage } from '../lib/types'
interface AssistantPanelProps { interface AssistantPanelProps {
projectName: string projectName: string
@@ -14,7 +18,83 @@ interface AssistantPanelProps {
onClose: () => void onClose: () => void
} }
const STORAGE_KEY_PREFIX = 'assistant-conversation-'
function getStoredConversationId(projectName: string): number | null {
try {
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${projectName}`)
if (stored) {
const data = JSON.parse(stored)
return data.conversationId || null
}
} catch {
// Invalid stored data, ignore
}
return null
}
function setStoredConversationId(projectName: string, conversationId: number | null) {
const key = `${STORAGE_KEY_PREFIX}${projectName}`
if (conversationId) {
localStorage.setItem(key, JSON.stringify({ conversationId }))
} else {
localStorage.removeItem(key)
}
}
export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelProps) { export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelProps) {
// Load initial conversation ID from localStorage
const [conversationId, setConversationId] = useState<number | null>(() =>
getStoredConversationId(projectName)
)
// Fetch conversation details when we have an ID
const { data: conversationDetail, isLoading: isLoadingConversation } = useConversation(
projectName,
conversationId
)
// Convert API messages to ChatMessage format for the chat component
const initialMessages: ChatMessage[] | undefined = conversationDetail?.messages.map((msg) => ({
id: `db-${msg.id}`,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
}))
console.log('[AssistantPanel] State:', {
conversationId,
isLoadingConversation,
conversationDetailId: conversationDetail?.id,
initialMessagesCount: initialMessages?.length ?? 0
})
// Persist conversation ID changes to localStorage
useEffect(() => {
setStoredConversationId(projectName, conversationId)
}, [projectName, conversationId])
// Reset conversation ID when project changes
useEffect(() => {
setConversationId(getStoredConversationId(projectName))
}, [projectName])
// Handle starting a new chat
const handleNewChat = useCallback(() => {
setConversationId(null)
}, [])
// Handle selecting a conversation from history
const handleSelectConversation = useCallback((id: number) => {
console.log('[AssistantPanel] handleSelectConversation called with id:', id)
setConversationId(id)
}, [])
// Handle when a new conversation is created (from WebSocket)
const handleConversationCreated = useCallback((id: number) => {
setConversationId(id)
}, [])
return ( return (
<> <>
{/* Backdrop - click to close */} {/* Backdrop - click to close */}
@@ -74,7 +154,17 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
{/* Chat area */} {/* Chat area */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{isOpen && <AssistantChat projectName={projectName} />} {isOpen && (
<AssistantChat
projectName={projectName}
conversationId={conversationId}
initialMessages={initialMessages}
isLoadingConversation={isLoadingConversation}
onNewChat={handleNewChat}
onSelectConversation={handleSelectConversation}
onConversationCreated={handleConversationCreated}
/>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -0,0 +1,202 @@
/**
* 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<AssistantConversation | null>(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) => {
console.log('[ConversationHistory] handleSelectConversation called with id:', conversationId)
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 */}
<div
className="fixed inset-0 z-40"
onClick={onClose}
/>
{/* Dropdown */}
<div
className="absolute top-full left-0 mt-2 neo-dropdown z-50 w-[320px] max-w-[calc(100vw-2rem)]"
style={{ boxShadow: 'var(--shadow-neo)' }}
>
{/* Header */}
<div className="px-3 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
<h3 className="font-bold text-sm">Conversation History</h3>
</div>
{/* Content */}
{isLoading ? (
<div className="p-4 flex items-center justify-center">
<Loader2 size={20} className="animate-spin text-[var(--color-neo-text-secondary)]" />
</div>
) : !conversations || conversations.length === 0 ? (
<div className="p-4 text-center text-[var(--color-neo-text-secondary)] text-sm">
No conversations yet
</div>
) : (
<div className="max-h-[300px] overflow-auto">
{conversations.map((conversation) => {
const isCurrent = conversation.id === currentConversationId
console.log('[ConversationHistory] Rendering conversation:', {
id: conversation.id,
currentConversationId,
isCurrent
})
return (
<div
key={conversation.id}
className={`flex items-center group ${
isCurrent
? 'bg-[var(--color-neo-pending)] text-[var(--color-neo-text-on-bright)]'
: ''
}`}
>
<button
onClick={() => handleSelectConversation(conversation.id)}
className="flex-1 neo-dropdown-item text-left"
disabled={isCurrent}
>
<div className="flex items-start gap-2">
<MessageSquare size={16} className="mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{conversation.title || 'Untitled conversation'}
</div>
<div className={`text-xs flex items-center gap-2 ${
isCurrent
? 'text-[var(--color-neo-text-on-bright)] opacity-80'
: 'text-[var(--color-neo-text-secondary)]'
}`}>
<span>{conversation.message_count} messages</span>
<span>|</span>
<span>{formatRelativeTime(conversation.updated_at)}</span>
</div>
</div>
</div>
</button>
<button
onClick={(e) => handleDeleteClick(e, conversation)}
className={`p-2 mr-2 transition-colors rounded ${
isCurrent
? 'text-[var(--color-neo-text-on-bright)] opacity-60 hover:opacity-100 hover:bg-[var(--color-neo-danger)]/20'
: 'text-[var(--color-neo-text-secondary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-neo-danger)] hover:bg-[var(--color-neo-danger)]/10'
}`}
title="Delete conversation"
>
<Trash2 size={14} />
</button>
</div>
)
})}
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={conversationToDelete !== null}
title="Delete Conversation"
message={`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
isLoading={deleteConversation.isPending}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

View File

@@ -120,6 +120,7 @@ export function useAssistantChat({
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) as AssistantChatServerMessage; const data = JSON.parse(event.data) as AssistantChatServerMessage;
console.log('[useAssistantChat] Received WebSocket message:', data.type, data);
switch (data.type) { switch (data.type) {
case "text": { case "text": {
@@ -277,6 +278,7 @@ export function useAssistantChat({
payload.conversation_id = existingConversationId; payload.conversation_id = existingConversationId;
setConversationId(existingConversationId); setConversationId(existingConversationId);
} }
console.log('[useAssistantChat] Sending start message:', payload);
wsRef.current.send(JSON.stringify(payload)); wsRef.current.send(JSON.stringify(payload));
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) { } else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
checkAndSendTimeoutRef.current = window.setTimeout(checkAndSend, 100); checkAndSendTimeoutRef.current = window.setTimeout(checkAndSend, 100);
@@ -336,7 +338,7 @@ export function useAssistantChat({
const clearMessages = useCallback(() => { const clearMessages = useCallback(() => {
setMessages([]); setMessages([]);
setConversationId(null); // Don't reset conversationId here - it will be set by start() when switching
}, []); }, []);
return { return {

View File

@@ -0,0 +1,47 @@
/**
* 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,
})
}
/**
* 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] })
},
})
}