.claude/skills/conversation-management/SKILL.md
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.
npx skillsauth add maneeshanif/todo-spec-driven conversation-managementInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick reference for building conversation history management for the Todo AI Chatbot Phase 3.
Users need to:
┌─────────────────────────────────────────────────────────────┐
│ Next.js Frontend │
├──────────────────────┬──────────────────────────────────────┤
│ Conversation │ Chat Area │
│ Sidebar │ │
│ ┌────────────────┐ │ ┌──────────────────────────────┐ │
│ │ + New Chat │ │ │ ChatKit / Custom Chat │ │
│ ├────────────────┤ │ │ │ │
│ │ Today │ │ │ Messages... │ │
│ │ ├─ Chat 1 ◄├──┼──┤ │ │
│ │ └─ Chat 2 │ │ │ │ │
│ │ Yesterday │ │ │ │ │
│ │ └─ Chat 3 │ │ │ │ │
│ └────────────────┘ │ └──────────────────────────────┘ │
└──────────────────────┴──────────────────────────────────────┘
| 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/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}
# backend/src/main.py
from src.routers import conversations
app.include_router(conversations.router)
// 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: [] });
},
}));
// 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;
}
// 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>
);
}
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
Backend:
Frontend:
tools
Implement WebSocket service for real-time task synchronization across clients. Use when building real-time updates for Phase 5. (project)
data-ai
Implement Urdu language support with RTL layout, translations, and AI responses in Urdu. Bonus feature (+100 points) for Phase 5. (project)
development
DEPRECATED - Use chatkit-backend skill instead. SSE streaming is now part of the chatkit-backend skill for ChatKit integration.
development
Install and configure Shadcn/ui component library with Radix UI primitives, Aceternity UI effects, set up components, and manage the component registry. Use when adding Shadcn/ui to a Next.js project or installing specific UI components for Phase 2.