mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-31 14:43:35 +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:
@@ -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 { 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,94 @@ 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)
|
||||
// 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
|
||||
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(() => {
|
||||
@@ -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 (
|
||||
<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 +257,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 +266,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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as AssistantChatServerMessage;
|
||||
console.log('[useAssistantChat] Received WebSocket message:', data.type, data);
|
||||
|
||||
switch (data.type) {
|
||||
case "text": {
|
||||
@@ -277,6 +278,7 @@ export function useAssistantChat({
|
||||
payload.conversation_id = existingConversationId;
|
||||
setConversationId(existingConversationId);
|
||||
}
|
||||
console.log('[useAssistantChat] Sending start message:', payload);
|
||||
wsRef.current.send(JSON.stringify(payload));
|
||||
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||
checkAndSendTimeoutRef.current = window.setTimeout(checkAndSend, 100);
|
||||
@@ -336,7 +338,7 @@ export function useAssistantChat({
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setConversationId(null);
|
||||
// Don't reset conversationId here - it will be set by start() when switching
|
||||
}, []);
|
||||
|
||||
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