mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-02 15:23:37 +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:
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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user