feat: add conversation history feature to AI assistant

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

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

View File

@@ -3,10 +3,14 @@
*
* Slide-in panel container for the project assistant chat.
* 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 { AssistantChat } from './AssistantChat'
import { useConversation } from '../hooks/useConversations'
import type { ChatMessage } from '../lib/types'
interface AssistantPanelProps {
projectName: string
@@ -14,7 +18,83 @@ interface AssistantPanelProps {
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) {
// 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 (
<>
{/* Backdrop - click to close */}
@@ -74,7 +154,17 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
{/* Chat area */}
<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>
</>