Skip to content
conversation-management logo

Conversation Management

conversation-management

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.

SKILL.md

Full skill instructions

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

MethodEndpointPurpose
GET/​api/​conversationsList user's conversations
POST/​api/​conversationsCreate 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

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View