conversation-management

Conversation Management

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "conversation-management" with this command: npx skills add maneeshanif/todo-spec-driven/maneeshanif-todo-spec-driven-conversation-management

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 */}
  &#x3C;ScrollArea className="flex-1">
    &#x3C;div className="p-2 space-y-4">
      {Object.entries(groupedConversations).map(([group, convs]) => (
        &#x3C;div key={group}>
          &#x3C;h3 className="px-2 py-1 text-xs font-medium text-muted-foreground">
            {group}
          &#x3C;/h3>
          &#x3C;div className="space-y-1">
            {convs.map(conv => (
              &#x3C;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)}
              />
            ))}
          &#x3C;/div>
        &#x3C;/div>
      ))}

      {conversations.length === 0 &#x26;&#x26; !isLoading &#x26;&#x26; (
        &#x3C;p className="text-center text-sm text-muted-foreground py-8">
          No conversations yet
        &#x3C;/p>
      )}
    &#x3C;/div>
  &#x3C;/ScrollArea>

  {/* Rename Dialog */}
  &#x3C;Dialog open={renameDialog.open} onOpenChange={(open) =>
    !open &#x26;&#x26; setRenameDialog({ open: false, id: 0, title: '' })
  }>
    &#x3C;DialogContent>
      &#x3C;DialogHeader>
        &#x3C;DialogTitle>Rename Conversation&#x3C;/DialogTitle>
      &#x3C;/DialogHeader>
      &#x3C;Input
        value={renameDialog.title}
        onChange={(e) => setRenameDialog(prev => ({
          ...prev,
          title: e.target.value,
        }))}
        placeholder="Enter new title"
        onKeyDown={(e) => e.key === 'Enter' &#x26;&#x26; handleRename()}
      />
      &#x3C;DialogFooter>
        &#x3C;Button variant="outline" onClick={() =>
          setRenameDialog({ open: false, id: 0, title: '' })
        }>
          Cancel
        &#x3C;/Button>
        &#x3C;Button onClick={handleRename}>Save&#x3C;/Button>
      &#x3C;/DialogFooter>
    &#x3C;/DialogContent>
  &#x3C;/Dialog>
&#x3C;/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 */}
  &#x3C;div className="flex-1 flex flex-col">
    {/* Header with toggle */}
    &#x3C;div className="h-12 border-b flex items-center px-4">
      &#x3C;Button
        variant="ghost"
        size="icon"
        onClick={() => setSidebarOpen(!sidebarOpen)}
      >
        {sidebarOpen ? (
          &#x3C;PanelLeftClose className="h-5 w-5" />
        ) : (
          &#x3C;PanelLeft className="h-5 w-5" />
        )}
      &#x3C;/Button>
    &#x3C;/div>

    {/* Chat Area */}
    &#x3C;div className="flex-1 overflow-hidden">
      {children}
    &#x3C;/div>
  &#x3C;/div>
&#x3C;/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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

dapr-integration

No summary provided by upstream source.

Repository SourceNeeds Review
General

chatkit-frontend

No summary provided by upstream source.

Repository SourceNeeds Review
General

better-auth-integration

No summary provided by upstream source.

Repository SourceNeeds Review
General

kafka-setup

No summary provided by upstream source.

Repository SourceNeeds Review