Merge pull request #74 from lirielgozi/feature-conversation-history

feature: add conversation history feature to AI assistant
This commit is contained in:
Leon van Zyl
2026-01-22 09:00:52 +02:00
committed by GitHub
12 changed files with 1304 additions and 49 deletions

View File

@@ -3,22 +3,41 @@
*
* Main chat interface for the project assistant.
* Displays messages and handles user input.
* Supports conversation history with resume functionality.
*/
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Loader2, Wifi, WifiOff } from 'lucide-react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react'
import { useAssistantChat } from '../hooks/useAssistantChat'
import { ChatMessage } from './ChatMessage'
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
import { ConversationHistory } from './ConversationHistory'
import type { ChatMessage } from '../lib/types'
interface AssistantChatProps {
projectName: string
conversationId?: number | null
initialMessages?: ChatMessage[]
isLoadingConversation?: boolean
onNewChat?: () => void
onSelectConversation?: (id: number) => void
onConversationCreated?: (id: number) => void
}
export function AssistantChat({ projectName }: AssistantChatProps) {
export function AssistantChat({
projectName,
conversationId,
initialMessages,
isLoadingConversation,
onNewChat,
onSelectConversation,
onConversationCreated,
}: AssistantChatProps) {
const [inputValue, setInputValue] = useState('')
const [showHistory, setShowHistory] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const hasStartedRef = useRef(false)
const lastConversationIdRef = useRef<number | null | undefined>(undefined)
// Memoize the error handler to prevent infinite re-renders
const handleError = useCallback((error: string) => {
@@ -29,25 +48,91 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
messages,
isLoading,
connectionStatus,
conversationId: activeConversationId,
start,
sendMessage,
clearMessages,
} = useAssistantChat({
projectName,
onError: handleError,
})
// Notify parent when a NEW conversation is created (not when switching to existing)
// Track activeConversationId to fire callback only once when it transitions from null to a value
const previousActiveConversationIdRef = useRef<number | null>(activeConversationId)
useEffect(() => {
const hadNoConversation = previousActiveConversationIdRef.current === null
const nowHasConversation = activeConversationId !== null
if (hadNoConversation && nowHasConversation && onConversationCreated) {
onConversationCreated(activeConversationId)
}
previousActiveConversationIdRef.current = activeConversationId
}, [activeConversationId, onConversationCreated])
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Start the chat session when component mounts (only once)
// Start or resume the chat session when component mounts or conversationId changes
useEffect(() => {
if (!hasStartedRef.current) {
hasStartedRef.current = true
start()
console.log('[AssistantChat] useEffect running:', {
conversationId,
isLoadingConversation,
lastRef: lastConversationIdRef.current,
hasStarted: hasStartedRef.current
})
// Skip if we're loading conversation details
if (isLoadingConversation) {
console.log('[AssistantChat] Skipping - loading conversation')
return
}
}, [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
useEffect(() => {
@@ -58,7 +143,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
const handleSend = () => {
const content = inputValue.trim()
if (!content || isLoading) return
if (!content || isLoading || isLoadingConversation) return
sendMessage(content)
setInputValue('')
@@ -71,31 +156,99 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
}
}
// Combine initial messages (from resumed conversation) with live messages
// Merge both arrays with deduplication by message ID to prevent history loss
const displayMessages = useMemo(() => {
const isConversationSynced = lastConversationIdRef.current === conversationId && !isLoadingConversation
// If not synced yet, show only initialMessages (or empty)
if (!isConversationSynced) {
return initialMessages ?? []
}
// If no initial messages, just show live messages
if (!initialMessages || initialMessages.length === 0) {
return messages
}
// Merge both arrays, deduplicating by ID (live messages take precedence)
const messageMap = new Map<string, ChatMessage>()
for (const msg of initialMessages) {
messageMap.set(msg.id, msg)
}
for (const msg of messages) {
messageMap.set(msg.id, msg)
}
return Array.from(messageMap.values())
}, [initialMessages, messages, conversationId, isLoadingConversation])
return (
<div className="flex flex-col h-full">
{/* Connection status indicator */}
<div className="flex items-center gap-2 px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
{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>
</>
)}
{/* Header with actions and connection status */}
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
{/* Action buttons */}
<div className="flex items-center gap-1 relative">
<button
onClick={handleNewChat}
className="neo-btn neo-btn-ghost p-1.5 text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)]"
title="New conversation"
disabled={isLoading}
>
<Plus size={16} />
</button>
<button
onClick={() => setShowHistory(!showHistory)}
className={`neo-btn neo-btn-ghost p-1.5 ${
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>
{/* Messages area */}
<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">
{isLoading ? (
<div className="flex items-center gap-2">
@@ -108,8 +261,8 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
</div>
) : (
<div className="py-4">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
{displayMessages.map((message) => (
<ChatMessageComponent key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
@@ -117,7 +270,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
</div>
{/* 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="flex items-center gap-2 text-[var(--color-neo-text-secondary)] text-sm">
<div className="flex gap-1">
@@ -139,7 +292,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
disabled={isLoading || connectionStatus !== 'connected'}
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'}
className="
flex-1
neo-input
@@ -152,7 +305,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) {
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading || connectionStatus !== 'connected'}
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
className="
neo-btn neo-btn-primary
px-4

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>
</>

View File

@@ -0,0 +1,196 @@
/**
* Conversation History Dropdown Component
*
* Displays a list of past conversations for the assistant.
* Allows selecting a conversation to resume or deleting old conversations.
*/
import { useState, useEffect } from 'react'
import { MessageSquare, Trash2, Loader2 } from 'lucide-react'
import { useConversations, useDeleteConversation } from '../hooks/useConversations'
import { ConfirmDialog } from './ConfirmDialog'
import type { AssistantConversation } from '../lib/types'
interface ConversationHistoryProps {
projectName: string
currentConversationId: number | null
isOpen: boolean
onClose: () => void
onSelectConversation: (conversationId: number) => void
}
/**
* Format a relative time string from an ISO date
*/
function formatRelativeTime(dateString: string | null): string {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSeconds < 60) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function ConversationHistory({
projectName,
currentConversationId,
isOpen,
onClose,
onSelectConversation,
}: ConversationHistoryProps) {
const [conversationToDelete, setConversationToDelete] = useState<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) => {
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
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}
/>
</>
)
}