conversation-management

maneeshanif's avatarfrom maneeshanif

Build conversation history UI with sidebar, thread switching, and conversation CRUD operations. Implements conversation list, rename, delete, and persistence. Use when adding conversation management features for Phase 3.

0stars🔀0forks📁View on GitHub🕐Updated Dec 30, 2025

When & Why to Use This Skill

This Claude skill provides a comprehensive full-stack blueprint for implementing persistent conversation history management in AI applications. It features a Next.js frontend with a responsive sidebar and thread-switching capabilities, integrated with a FastAPI backend for robust CRUD operations. By automating the setup of conversation lists, renaming, and deletion, it enables developers to quickly build organized, user-friendly chat interfaces that maintain context across sessions.

Use Cases

  • Developing a multi-thread AI chatbot interface that allows users to switch between different topics seamlessly.
  • Implementing persistent chat storage using FastAPI and SQLModel to ensure user conversations are saved and retrievable.
  • Building a responsive sidebar for web-based AI assistants that automatically groups chat history by date (e.g., Today, Yesterday, Last Week).
  • Adding administrative conversation features such as renaming threads for better organization or deleting unwanted history to enhance user data control.
nameconversation-management
descriptionBuild conversation history UI with sidebar, thread switching, and conversation CRUD operations. Implements conversation list, rename, delete, and persistence. Use when adding conversation management features for Phase 3.
allowed-toolsBash, Write, Read, Edit, Glob

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

conversation-management – AI Agent Skills | Claude Skills