Conversation Management
Quick reference for building conversation history management for the Todo AI Chatbot Phase 3.
Overview
Users need to:
-
View past conversations - List all their chat threads
-
Switch conversations - Resume previous chats
-
Create new conversations - Start fresh threads
-
Rename conversations - Give meaningful titles
-
Delete conversations - Remove unwanted history
Architecture
┌─────────────────────────────────────────────────────────────┐ │ Next.js Frontend │ ├──────────────────────┬──────────────────────────────────────┤ │ Conversation │ Chat Area │ │ Sidebar │ │ │ ┌────────────────┐ │ ┌──────────────────────────────┐ │ │ │ + New Chat │ │ │ ChatKit / Custom Chat │ │ │ ├────────────────┤ │ │ │ │ │ │ Today │ │ │ Messages... │ │ │ │ ├─ Chat 1 ◄├──┼──┤ │ │ │ │ └─ Chat 2 │ │ │ │ │ │ │ Yesterday │ │ │ │ │ │ │ └─ Chat 3 │ │ │ │ │ │ └────────────────┘ │ └──────────────────────────────┘ │ └──────────────────────┴──────────────────────────────────────┘
Backend API Endpoints
Conversation Endpoints
Method Endpoint Purpose
GET
/api/conversations
List user's conversations
POST
/api/conversations
Create new conversation
GET
/api/conversations/{id}
Get conversation with messages
PATCH
/api/conversations/{id}
Rename conversation
DELETE
/api/conversations/{id}
Delete conversation
Backend Implementation
Conversation Router
backend/src/routers/conversations.py
from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select, desc from src.database import get_session from src.middleware.auth import verify_jwt from src.models.conversation import Conversation from src.models.message import Message from pydantic import BaseModel from datetime import datetime
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
class ConversationCreate(BaseModel): title: str | None = None
class ConversationUpdate(BaseModel): title: str
class ConversationResponse(BaseModel): id: int title: str | None created_at: datetime updated_at: datetime message_count: int = 0 preview: str | None = None
class ConversationDetailResponse(ConversationResponse): messages: list[dict]
@router.get("/", response_model=list[ConversationResponse]) async def list_conversations( limit: int = Query(default=50, le=100), offset: int = Query(default=0, ge=0), session: Session = Depends(get_session), current_user: dict = Depends(verify_jwt), ): """ List all conversations for the authenticated user. Ordered by most recently updated. """ user_id = current_user["id"]
# Query conversations with message count
conversations = session.exec(
select(Conversation)
.where(Conversation.user_id == user_id)
.order_by(desc(Conversation.updated_at))
.offset(offset)
.limit(limit)
).all()
result = []
for conv in conversations:
# Get message count
message_count = len(conv.messages) if conv.messages else 0
# Get preview from last message
preview = None
if conv.messages:
last_msg = sorted(conv.messages, key=lambda m: m.created_at)[-1]
preview = last_msg.content[:100] + "..." if len(last_msg.content) > 100 else last_msg.content
result.append(ConversationResponse(
id=conv.id,
title=conv.title,
created_at=conv.created_at,
updated_at=conv.updated_at,
message_count=message_count,
preview=preview,
))
return result
@router.post("/", response_model=ConversationResponse) async def create_conversation( data: ConversationCreate, session: Session = Depends(get_session), current_user: dict = Depends(verify_jwt), ): """Create a new conversation.""" user_id = current_user["id"]
conversation = Conversation(
user_id=user_id,
title=data.title or "New Conversation",
)
session.add(conversation)
session.commit()
session.refresh(conversation)
return ConversationResponse(
id=conversation.id,
title=conversation.title,
created_at=conversation.created_at,
updated_at=conversation.updated_at,
message_count=0,
)
@router.get("/{conversation_id}", response_model=ConversationDetailResponse) async def get_conversation( conversation_id: int, session: Session = Depends(get_session), current_user: dict = Depends(verify_jwt), ): """Get conversation with all messages.""" user_id = current_user["id"]
conversation = session.exec(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
).first()
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# Get messages
messages = session.exec(
select(Message)
.where(Message.conversation_id == conversation_id)
.order_by(Message.created_at)
).all()
return ConversationDetailResponse(
id=conversation.id,
title=conversation.title,
created_at=conversation.created_at,
updated_at=conversation.updated_at,
message_count=len(messages),
messages=[
{
"id": msg.id,
"role": msg.role,
"content": msg.content,
"created_at": msg.created_at.isoformat(),
}
for msg in messages
],
)
@router.patch("/{conversation_id}", response_model=ConversationResponse) async def update_conversation( conversation_id: int, data: ConversationUpdate, session: Session = Depends(get_session), current_user: dict = Depends(verify_jwt), ): """Rename a conversation.""" user_id = current_user["id"]
conversation = session.exec(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
).first()
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
conversation.title = data.title
conversation.updated_at = datetime.utcnow()
session.add(conversation)
session.commit()
session.refresh(conversation)
return ConversationResponse(
id=conversation.id,
title=conversation.title,
created_at=conversation.created_at,
updated_at=conversation.updated_at,
message_count=len(conversation.messages) if conversation.messages else 0,
)
@router.delete("/{conversation_id}") async def delete_conversation( conversation_id: int, session: Session = Depends(get_session), current_user: dict = Depends(verify_jwt), ): """Delete a conversation and all its messages.""" user_id = current_user["id"]
conversation = session.exec(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
).first()
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# Delete messages first (cascade)
session.exec(
select(Message).where(Message.conversation_id == conversation_id)
)
for msg in conversation.messages:
session.delete(msg)
# Delete conversation
session.delete(conversation)
session.commit()
return {"status": "deleted", "conversation_id": conversation_id}
Register Router
backend/src/main.py
from src.routers import conversations
app.include_router(conversations.router)
Frontend Implementation
Conversation Store (Zustand)
// frontend/src/stores/conversationStore.ts import { create } from 'zustand'; import { apiClient } from '@/lib/api';
interface Conversation { id: number; title: string | null; created_at: string; updated_at: string; message_count: number; preview?: string; }
interface Message { id: number; role: 'user' | 'assistant'; content: string; created_at: string; }
interface ConversationState { conversations: Conversation[]; currentConversation: Conversation | null; messages: Message[]; isLoading: boolean; error: string | null;
// Actions fetchConversations: () => Promise<void>; selectConversation: (id: number) => Promise<void>; createConversation: (title?: string) => Promise<Conversation>; updateConversation: (id: number, title: string) => Promise<void>; deleteConversation: (id: number) => Promise<void>; addMessage: (message: Message) => void; clearCurrentConversation: () => void; }
export const useConversationStore = create<ConversationState>((set, get) => ({ conversations: [], currentConversation: null, messages: [], isLoading: false, error: null,
fetchConversations: async () => { set({ isLoading: true, error: null }); try { const response = await apiClient.get('/api/conversations'); set({ conversations: response.data, isLoading: false }); } catch (error) { set({ error: 'Failed to fetch conversations', isLoading: false }); } },
selectConversation: async (id: number) => {
set({ isLoading: true, error: null });
try {
const response = await apiClient.get(/api/conversations/${id});
set({
currentConversation: response.data,
messages: response.data.messages,
isLoading: false,
});
} catch (error) {
set({ error: 'Failed to load conversation', isLoading: false });
}
},
createConversation: async (title?: string) => { const response = await apiClient.post('/api/conversations', { title }); const newConv = response.data; set(state => ({ conversations: [newConv, ...state.conversations], currentConversation: newConv, messages: [], })); return newConv; },
updateConversation: async (id: number, title: string) => {
const response = await apiClient.patch(/api/conversations/${id}, { title });
set(state => ({
conversations: state.conversations.map(c =>
c.id === id ? { ...c, title } : c
),
currentConversation: state.currentConversation?.id === id
? { ...state.currentConversation, title }
: state.currentConversation,
}));
},
deleteConversation: async (id: number) => {
await apiClient.delete(/api/conversations/${id});
set(state => ({
conversations: state.conversations.filter(c => c.id !== id),
currentConversation: state.currentConversation?.id === id
? null
: state.currentConversation,
messages: state.currentConversation?.id === id ? [] : state.messages,
}));
},
addMessage: (message: Message) => { set(state => ({ messages: [...state.messages, message], })); },
clearCurrentConversation: () => { set({ currentConversation: null, messages: [] }); }, }));
Conversation Sidebar
// frontend/src/components/chat/ConversationSidebar.tsx 'use client';
import { useEffect, useState } from 'react'; import { useConversationStore } from '@/stores/conversationStore'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Plus, MessageSquare, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatDistanceToNow } from 'date-fns';
export function ConversationSidebar() { const { conversations, currentConversation, isLoading, fetchConversations, selectConversation, createConversation, updateConversation, deleteConversation, clearCurrentConversation, } = useConversationStore();
const [renameDialog, setRenameDialog] = useState<{ open: boolean; id: number; title: string; }>({ open: false, id: 0, title: '' });
useEffect(() => { fetchConversations(); }, [fetchConversations]);
const handleNewChat = async () => { clearCurrentConversation(); // Optionally create conversation immediately or wait for first message };
const handleRename = async () => { if (renameDialog.title.trim()) { await updateConversation(renameDialog.id, renameDialog.title.trim()); setRenameDialog({ open: false, id: 0, title: '' }); } };
const handleDelete = async (id: number) => { if (confirm('Delete this conversation? This cannot be undone.')) { await deleteConversation(id); } };
// Group conversations by date const groupedConversations = groupByDate(conversations);
return ( <div className="w-64 h-full border-r bg-muted/30 flex flex-col"> {/* New Chat Button */} <div className="p-3 border-b"> <Button onClick={handleNewChat} className="w-full justify-start gap-2" variant="outline" > <Plus className="h-4 w-4" /> New Chat </Button> </div>
{/* Conversation List */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-4">
{Object.entries(groupedConversations).map(([group, convs]) => (
<div key={group}>
<h3 className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group}
</h3>
<div className="space-y-1">
{convs.map(conv => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={currentConversation?.id === conv.id}
onSelect={() => selectConversation(conv.id)}
onRename={() => setRenameDialog({
open: true,
id: conv.id,
title: conv.title || '',
})}
onDelete={() => handleDelete(conv.id)}
/>
))}
</div>
</div>
))}
{conversations.length === 0 && !isLoading && (
<p className="text-center text-sm text-muted-foreground py-8">
No conversations yet
</p>
)}
</div>
</ScrollArea>
{/* Rename Dialog */}
<Dialog open={renameDialog.open} onOpenChange={(open) =>
!open && setRenameDialog({ open: false, id: 0, title: '' })
}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Conversation</DialogTitle>
</DialogHeader>
<Input
value={renameDialog.title}
onChange={(e) => setRenameDialog(prev => ({
...prev,
title: e.target.value,
}))}
placeholder="Enter new title"
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/>
<DialogFooter>
<Button variant="outline" onClick={() =>
setRenameDialog({ open: false, id: 0, title: '' })
}>
Cancel
</Button>
<Button onClick={handleRename}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
); }
interface ConversationItemProps { conversation: { id: number; title: string | null; preview?: string; updated_at: string; }; isActive: boolean; onSelect: () => void; onRename: () => void; onDelete: () => void; }
function ConversationItem({ conversation, isActive, onSelect, onRename, onDelete, }: ConversationItemProps) { return ( <div className={cn( "group flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer hover:bg-muted", isActive && "bg-muted" )} onClick={onSelect} > <MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" /> <div className="flex-1 min-w-0"> <p className="text-sm font-medium truncate"> {conversation.title || 'New Chat'} </p> {conversation.preview && ( <p className="text-xs text-muted-foreground truncate"> {conversation.preview} </p> )} </div> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={(e) => e.stopPropagation()} > <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRename(); }}> <Pencil className="h-4 w-4 mr-2" /> Rename </DropdownMenuItem> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-destructive" > <Trash2 className="h-4 w-4 mr-2" /> Delete </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ); }
// Helper function to group conversations by date function groupByDate(conversations: Array<{ updated_at: string; [key: string]: any }>) { const groups: Record<string, typeof conversations> = {};
const today = new Date(); today.setHours(0, 0, 0, 0);
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
const lastWeek = new Date(today); lastWeek.setDate(lastWeek.getDate() - 7);
conversations.forEach(conv => { const date = new Date(conv.updated_at); date.setHours(0, 0, 0, 0);
let group: string;
if (date >= today) {
group = 'Today';
} else if (date >= yesterday) {
group = 'Yesterday';
} else if (date >= lastWeek) {
group = 'Previous 7 Days';
} else {
group = 'Older';
}
if (!groups[group]) groups[group] = [];
groups[group].push(conv);
});
return groups; }
Chat Layout with Sidebar
// frontend/src/app/chat/layout.tsx 'use client';
import { ConversationSidebar } from '@/components/chat/ConversationSidebar'; import { useAuthStore } from '@/stores/authStore'; import { redirect } from 'next/navigation'; import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { PanelLeftClose, PanelLeft } from 'lucide-react';
export default function ChatLayout({ children, }: { children: React.ReactNode; }) { const { isAuthenticated, isLoading } = useAuthStore(); const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => { if (!isLoading && !isAuthenticated) { redirect('/login'); } }, [isAuthenticated, isLoading]);
if (isLoading) { return <div className="h-screen flex items-center justify-center">Loading...</div>; }
return ( <div className="h-screen flex"> {/* Sidebar */} {sidebarOpen && <ConversationSidebar />}
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header with toggle */}
<div className="h-12 border-b flex items-center px-4">
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? (
<PanelLeftClose className="h-5 w-5" />
) : (
<PanelLeft className="h-5 w-5" />
)}
</Button>
</div>
{/* Chat Area */}
<div className="flex-1 overflow-hidden">
{children}
</div>
</div>
</div>
); }
Project Structure
frontend/src/ ├── app/ │ └── chat/ │ ├── layout.tsx # Layout with sidebar │ └── page.tsx # Chat page with ChatKit │ ├── components/ │ └── chat/ │ ├── ConversationSidebar.tsx # Sidebar component │ ├── ConversationItem.tsx # Individual conversation │ └── ChatContainer.tsx # Main chat area │ ├── stores/ │ └── conversationStore.ts # Zustand store │ └── lib/ └── api.ts # API client
Verification Checklist
Backend:
-
Conversation CRUD endpoints implemented
-
User isolation enforced
-
Messages cascade delete with conversation
-
Pagination support for large lists
Frontend:
-
Conversation list fetches on mount
-
Can create new conversations
-
Can select and load conversation
-
Can rename conversations
-
Can delete conversations
-
Sidebar groups by date
-
Responsive on mobile (collapsible sidebar)
See Also
-
chat-api-integration - Chat endpoint setup
-
streaming-sse-setup - Real-time streaming
-
openai-chatkit-setup - ChatKit UI
-
OpenAI ChatKit Docs - Official ChatKit documentation
-
Domain Allowlist - Required for ChatKit production deployment