mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
563
ui/e2e/conversation-history.spec.ts
Normal file
563
ui/e2e/conversation-history.spec.ts
Normal 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
120
ui/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
25
ui/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
202
ui/src/components/ConversationHistory.tsx
Normal file
202
ui/src/components/ConversationHistory.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
47
ui/src/hooks/useConversations.ts
Normal file
47
ui/src/hooks/useConversations.ts
Normal 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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user