fix: address performance and code quality issues in conversation history

Performance improvements:
- Fix N+1 query in get_conversations() using COUNT subquery instead of
  len(c.messages) which triggered lazy loading for each conversation
- Add SQLAlchemy engine caching to avoid creating new database connections
  on every request
- Add React.memo to ChatMessage component to prevent unnecessary re-renders
  during message streaming
- Move BOLD_REGEX to module scope to avoid recreating on each render

Code quality improvements:
- Remove 10+ console.log debug statements from AssistantChat.tsx and
  AssistantPanel.tsx that were left from development
- Add user feedback for delete errors in ConversationHistory - dialog now
  stays open and shows error message instead of silently failing
- Update ConfirmDialog to accept ReactNode for message prop to support
  rich error content

These changes address issues identified in the code review of PR #74
(conversation history feature).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-22 09:09:05 +02:00
parent 35ed14dfe3
commit 28e8bd6da8
6 changed files with 91 additions and 55 deletions

View File

@@ -11,13 +11,17 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine, func
from sqlalchemy.orm import declarative_base, relationship, sessionmaker from sqlalchemy.orm import declarative_base, relationship, sessionmaker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Base = declarative_base() Base = declarative_base()
# Engine cache to avoid creating new engines for each request
# Key: project directory path (as posix string), Value: SQLAlchemy engine
_engine_cache: dict[str, object] = {}
def _utc_now() -> datetime: def _utc_now() -> datetime:
"""Return current UTC time. Replacement for deprecated datetime.utcnow().""" """Return current UTC time. Replacement for deprecated datetime.utcnow()."""
@@ -56,13 +60,23 @@ def get_db_path(project_dir: Path) -> Path:
def get_engine(project_dir: Path): def get_engine(project_dir: Path):
"""Get or create a SQLAlchemy engine for a project's assistant database.""" """Get or create a SQLAlchemy engine for a project's assistant database.
db_path = get_db_path(project_dir)
# Use as_posix() for cross-platform compatibility with SQLite connection strings Uses a cache to avoid creating new engines for each request, which improves
db_url = f"sqlite:///{db_path.as_posix()}" performance by reusing database connections.
engine = create_engine(db_url, echo=False) """
Base.metadata.create_all(engine) cache_key = project_dir.as_posix()
return engine
if cache_key not in _engine_cache:
db_path = get_db_path(project_dir)
# Use as_posix() for cross-platform compatibility with SQLite connection strings
db_url = f"sqlite:///{db_path.as_posix()}"
engine = create_engine(db_url, echo=False)
Base.metadata.create_all(engine)
_engine_cache[cache_key] = engine
logger.debug(f"Created new database engine for {cache_key}")
return _engine_cache[cache_key]
def get_session(project_dir: Path): def get_session(project_dir: Path):
@@ -94,23 +108,44 @@ def create_conversation(project_dir: Path, project_name: str, title: Optional[st
def get_conversations(project_dir: Path, project_name: str) -> list[dict]: def get_conversations(project_dir: Path, project_name: str) -> list[dict]:
"""Get all conversations for a project with message counts.""" """Get all conversations for a project with message counts.
Uses a subquery for message_count to avoid N+1 query problem.
"""
session = get_session(project_dir) session = get_session(project_dir)
try: try:
# Subquery to count messages per conversation (avoids N+1 query)
message_count_subquery = (
session.query(
ConversationMessage.conversation_id,
func.count(ConversationMessage.id).label("message_count")
)
.group_by(ConversationMessage.conversation_id)
.subquery()
)
# Join conversation with message counts
conversations = ( conversations = (
session.query(Conversation) session.query(
Conversation,
func.coalesce(message_count_subquery.c.message_count, 0).label("message_count")
)
.outerjoin(
message_count_subquery,
Conversation.id == message_count_subquery.c.conversation_id
)
.filter(Conversation.project_name == project_name) .filter(Conversation.project_name == project_name)
.order_by(Conversation.updated_at.desc()) .order_by(Conversation.updated_at.desc())
.all() .all()
) )
return [ return [
{ {
"id": c.id, "id": c.Conversation.id,
"project_name": c.project_name, "project_name": c.Conversation.project_name,
"title": c.title, "title": c.Conversation.title,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": c.Conversation.created_at.isoformat() if c.Conversation.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None, "updated_at": c.Conversation.updated_at.isoformat() if c.Conversation.updated_at else None,
"message_count": len(c.messages), "message_count": c.message_count,
} }
for c in conversations for c in conversations
] ]

View File

@@ -78,22 +78,13 @@ export function AssistantChat({
// Start or resume the chat session when component mounts or conversationId changes // Start or resume the chat session when component mounts or conversationId changes
useEffect(() => { useEffect(() => {
console.log('[AssistantChat] useEffect running:', {
conversationId,
isLoadingConversation,
lastRef: lastConversationIdRef.current,
hasStarted: hasStartedRef.current
})
// Skip if we're loading conversation details // Skip if we're loading conversation details
if (isLoadingConversation) { if (isLoadingConversation) {
console.log('[AssistantChat] Skipping - loading conversation')
return return
} }
// Only start if conversationId has actually changed // Only start if conversationId has actually changed
if (lastConversationIdRef.current === conversationId && hasStartedRef.current) { if (lastConversationIdRef.current === conversationId && hasStartedRef.current) {
console.log('[AssistantChat] Skipping - same conversationId')
return return
} }
@@ -101,23 +92,15 @@ export function AssistantChat({
const isSwitching = lastConversationIdRef.current !== undefined && const isSwitching = lastConversationIdRef.current !== undefined &&
lastConversationIdRef.current !== conversationId lastConversationIdRef.current !== conversationId
console.log('[AssistantChat] Processing conversation change:', {
from: lastConversationIdRef.current,
to: conversationId,
isSwitching
})
lastConversationIdRef.current = conversationId lastConversationIdRef.current = conversationId
hasStartedRef.current = true hasStartedRef.current = true
// Clear existing messages when switching conversations // Clear existing messages when switching conversations
if (isSwitching) { if (isSwitching) {
console.log('[AssistantChat] Clearing messages for conversation switch')
clearMessages() clearMessages()
} }
// Start the session with the conversation ID (or null for new) // Start the session with the conversation ID (or null for new)
console.log('[AssistantChat] Starting session with conversationId:', conversationId)
start(conversationId) start(conversationId)
}, [conversationId, isLoadingConversation, start, clearMessages]) }, [conversationId, isLoadingConversation, start, clearMessages])
@@ -129,7 +112,6 @@ export function AssistantChat({
// Handle selecting a conversation from history // Handle selecting a conversation from history
const handleSelectConversation = useCallback((id: number) => { const handleSelectConversation = useCallback((id: number) => {
console.log('[AssistantChat] handleSelectConversation called with id:', id)
setShowHistory(false) setShowHistory(false)
onSelectConversation?.(id) onSelectConversation?.(id)
}, [onSelectConversation]) }, [onSelectConversation])

View File

@@ -62,13 +62,6 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(), 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 // Persist conversation ID changes to localStorage
useEffect(() => { useEffect(() => {
setStoredConversationId(projectName, conversationId) setStoredConversationId(projectName, conversationId)
@@ -86,7 +79,6 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
// Handle selecting a conversation from history // Handle selecting a conversation from history
const handleSelectConversation = useCallback((id: number) => { const handleSelectConversation = useCallback((id: number) => {
console.log('[AssistantPanel] handleSelectConversation called with id:', id)
setConversationId(id) setConversationId(id)
}, []) }, [])

View File

@@ -5,6 +5,7 @@
* Supports user, assistant, and system messages with neobrutalism styling. * Supports user, assistant, and system messages with neobrutalism styling.
*/ */
import { memo } from 'react'
import { Bot, User, Info } from 'lucide-react' import { Bot, User, Info } from 'lucide-react'
import type { ChatMessage as ChatMessageType } from '../lib/types' import type { ChatMessage as ChatMessageType } from '../lib/types'
@@ -12,7 +13,10 @@ interface ChatMessageProps {
message: ChatMessageType message: ChatMessageType
} }
export function ChatMessage({ message }: ChatMessageProps) { // Module-level regex to avoid recreating on each render
const BOLD_REGEX = /\*\*(.*?)\*\*/g
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
const { role, content, attachments, timestamp, isStreaming } = message const { role, content, attachments, timestamp, isStreaming } = message
// Format timestamp // Format timestamp
@@ -112,13 +116,13 @@ export function ChatMessage({ message }: ChatMessageProps) {
{content && ( {content && (
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}> <div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
{content.split('\n').map((line, i) => { {content.split('\n').map((line, i) => {
// Bold text // Bold text - use module-level regex, reset lastIndex for each line
const boldRegex = /\*\*(.*?)\*\*/g BOLD_REGEX.lastIndex = 0
const parts = [] const parts = []
let lastIndex = 0 let lastIndex = 0
let match let match
while ((match = boldRegex.exec(line)) !== null) { while ((match = BOLD_REGEX.exec(line)) !== null) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
parts.push(line.slice(lastIndex, match.index)) parts.push(line.slice(lastIndex, match.index))
} }
@@ -196,4 +200,4 @@ export function ChatMessage({ message }: ChatMessageProps) {
</div> </div>
</div> </div>
) )
} })

View File

@@ -5,12 +5,13 @@
* Used to confirm destructive actions like deleting projects. * Used to confirm destructive actions like deleting projects.
*/ */
import type { ReactNode } from 'react'
import { AlertTriangle, X } from 'lucide-react' import { AlertTriangle, X } from 'lucide-react'
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean isOpen: boolean
title: string title: string
message: string message: ReactNode
confirmLabel?: string confirmLabel?: string
cancelLabel?: string cancelLabel?: string
variant?: 'danger' | 'warning' variant?: 'danger' | 'warning'
@@ -75,9 +76,9 @@ export function ConfirmDialog({
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<p className="text-[var(--color-neo-text-secondary)] mb-6"> <div className="text-[var(--color-neo-text-secondary)] mb-6">
{message} {message}
</p> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">

View File

@@ -6,7 +6,7 @@
*/ */
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { MessageSquare, Trash2, Loader2 } from 'lucide-react' import { MessageSquare, Trash2, Loader2, AlertCircle } from 'lucide-react'
import { useConversations, useDeleteConversation } from '../hooks/useConversations' import { useConversations, useDeleteConversation } from '../hooks/useConversations'
import { ConfirmDialog } from './ConfirmDialog' import { ConfirmDialog } from './ConfirmDialog'
import type { AssistantConversation } from '../lib/types' import type { AssistantConversation } from '../lib/types'
@@ -50,10 +50,18 @@ export function ConversationHistory({
onSelectConversation, onSelectConversation,
}: ConversationHistoryProps) { }: ConversationHistoryProps) {
const [conversationToDelete, setConversationToDelete] = useState<AssistantConversation | null>(null) const [conversationToDelete, setConversationToDelete] = useState<AssistantConversation | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
const { data: conversations, isLoading } = useConversations(projectName) const { data: conversations, isLoading } = useConversations(projectName)
const deleteConversation = useDeleteConversation(projectName) const deleteConversation = useDeleteConversation(projectName)
// Clear error when dropdown closes
useEffect(() => {
if (!isOpen) {
setDeleteError(null)
}
}, [isOpen])
const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => { const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => {
e.stopPropagation() e.stopPropagation()
setConversationToDelete(conversation) setConversationToDelete(conversation)
@@ -63,16 +71,18 @@ export function ConversationHistory({
if (!conversationToDelete) return if (!conversationToDelete) return
try { try {
setDeleteError(null)
await deleteConversation.mutateAsync(conversationToDelete.id) await deleteConversation.mutateAsync(conversationToDelete.id)
setConversationToDelete(null) setConversationToDelete(null)
} catch (error) { } catch {
console.error('Failed to delete conversation:', error) // Keep dialog open and show error to user
setConversationToDelete(null) setDeleteError('Failed to delete conversation. Please try again.')
} }
} }
const handleCancelDelete = () => { const handleCancelDelete = () => {
setConversationToDelete(null) setConversationToDelete(null)
setDeleteError(null)
} }
const handleSelectConversation = (conversationId: number) => { const handleSelectConversation = (conversationId: number) => {
@@ -183,7 +193,19 @@ export function ConversationHistory({
<ConfirmDialog <ConfirmDialog
isOpen={conversationToDelete !== null} isOpen={conversationToDelete !== null}
title="Delete Conversation" title="Delete Conversation"
message={`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`} message={
deleteError ? (
<div className="space-y-3">
<p>{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}</p>
<div className="flex items-center gap-2 p-2 bg-[var(--color-neo-danger)]/10 border border-[var(--color-neo-danger)] rounded text-sm text-[var(--color-neo-danger)]">
<AlertCircle size={16} className="flex-shrink-0" />
<span>{deleteError}</span>
</div>
</div>
) : (
`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`
)
}
confirmLabel="Delete" confirmLabel="Delete"
cancelLabel="Cancel" cancelLabel="Cancel"
variant="danger" variant="danger"