diff --git a/apps/gateway/src/conversations/conversations.dto.ts b/apps/gateway/src/conversations/conversations.dto.ts index 4f70afd..369ef33 100644 --- a/apps/gateway/src/conversations/conversations.dto.ts +++ b/apps/gateway/src/conversations/conversations.dto.ts @@ -1,4 +1,12 @@ -import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { + IsBoolean, + IsIn, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; export class CreateConversationDto { @IsOptional() @@ -20,6 +28,10 @@ export class UpdateConversationDto { @IsOptional() @IsUUID() projectId?: string | null; + + @IsOptional() + @IsBoolean() + archived?: boolean; } export class SendMessageDto { diff --git a/apps/web/src/app/(dashboard)/chat/page.tsx b/apps/web/src/app/(dashboard)/chat/page.tsx index fa56994..a04867d 100644 --- a/apps/web/src/app/(dashboard)/chat/page.tsx +++ b/apps/web/src/app/(dashboard)/chat/page.tsx @@ -141,19 +141,72 @@ export default function ChatPage(): React.ReactElement { setMessages([]); }, []); + const handleRename = useCallback(async (id: string, title: string) => { + const updated = await api(`/api/conversations/${id}`, { + method: 'PATCH', + body: { title }, + }); + setConversations((prev) => prev.map((c) => (c.id === id ? updated : c))); + }, []); + + const handleDelete = useCallback( + async (id: string) => { + await api(`/api/conversations/${id}`, { method: 'DELETE' }); + setConversations((prev) => prev.filter((c) => c.id !== id)); + if (activeId === id) { + setActiveId(null); + setMessages([]); + } + }, + [activeId], + ); + + const handleArchive = useCallback( + async (id: string, archived: boolean) => { + const updated = await api(`/api/conversations/${id}`, { + method: 'PATCH', + body: { archived }, + }); + setConversations((prev) => prev.map((c) => (c.id === id ? updated : c))); + // If archiving the active conversation, deselect it + if (archived && activeId === id) { + setActiveId(null); + setMessages([]); + } + }, + [activeId], + ); + const handleSend = useCallback( async (content: string) => { let convId = activeId; // Auto-create conversation if none selected if (!convId) { + const autoTitle = content.slice(0, 60); const conv = await api('/api/conversations', { method: 'POST', - body: { title: content.slice(0, 50) }, + body: { title: autoTitle }, }); setConversations((prev) => [conv, ...prev]); setActiveId(conv.id); convId = conv.id; + } else { + // Auto-title: if the active conversation still has the default "New + // conversation" title and this is the first message, update the title + // from the message content. + const activeConv = conversations.find((c) => c.id === convId); + if (activeConv?.title === 'New conversation' && messages.length === 0) { + const autoTitle = content.slice(0, 60); + api(`/api/conversations/${convId}`, { + method: 'PATCH', + body: { title: autoTitle }, + }) + .then((updated) => { + setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c))); + }) + .catch(() => {}); + } } // Optimistic user message in local UI state @@ -186,7 +239,7 @@ export default function ChatPage(): React.ReactElement { } socket.emit('message', { conversationId: convId, content }); }, - [activeId], + [activeId, conversations, messages], ); return ( @@ -196,6 +249,9 @@ export default function ChatPage(): React.ReactElement { activeId={activeId} onSelect={setActiveId} onNew={handleNewConversation} + onRename={handleRename} + onDelete={handleDelete} + onArchive={handleArchive} />
diff --git a/apps/web/src/components/chat/conversation-list.tsx b/apps/web/src/components/chat/conversation-list.tsx index 020e508..ba1cca2 100644 --- a/apps/web/src/components/chat/conversation-list.tsx +++ b/apps/web/src/components/chat/conversation-list.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback, useRef, useState } from 'react'; import { cn } from '@/lib/cn'; import type { Conversation } from '@/lib/types'; @@ -8,6 +9,32 @@ interface ConversationListProps { activeId: string | null; onSelect: (id: string) => void; onNew: () => void; + onRename: (id: string, title: string) => void; + onDelete: (id: string) => void; + onArchive: (id: string, archived: boolean) => void; +} + +interface ContextMenuState { + conversationId: string; + x: number; + y: number; +} + +/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */ +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); } export function ConversationList({ @@ -15,43 +42,265 @@ export function ConversationList({ activeId, onSelect, onNew, + onRename, + onDelete, + onArchive, }: ConversationListProps): React.ReactElement { - return ( -
-
-

Conversations

- -
+ const [searchQuery, setSearchQuery] = useState(''); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [contextMenu, setContextMenu] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [showArchived, setShowArchived] = useState(false); + const renameInputRef = useRef(null); -
- {conversations.length === 0 && ( -

No conversations yet

- )} - {conversations.map((conv) => ( + const activeConversations = conversations.filter((c) => !c.archived); + const archivedConversations = conversations.filter((c) => c.archived); + + const filteredActive = searchQuery + ? activeConversations.filter((c) => + (c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()), + ) + : activeConversations; + + const filteredArchived = searchQuery + ? archivedConversations.filter((c) => + (c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()), + ) + : archivedConversations; + + const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => { + e.preventDefault(); + setContextMenu({ conversationId, x: e.clientX, y: e.clientY }); + setDeleteConfirmId(null); + }, []); + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + setDeleteConfirmId(null); + }, []); + + const startRename = useCallback( + (id: string, currentTitle: string | null) => { + setRenamingId(id); + setRenameValue(currentTitle ?? ''); + closeContextMenu(); + setTimeout(() => renameInputRef.current?.focus(), 0); + }, + [closeContextMenu], + ); + + const commitRename = useCallback(() => { + if (renamingId) { + const trimmed = renameValue.trim(); + onRename(renamingId, trimmed || 'Untitled'); + } + setRenamingId(null); + setRenameValue(''); + }, [renamingId, renameValue, onRename]); + + const cancelRename = useCallback(() => { + setRenamingId(null); + setRenameValue(''); + }, []); + + const handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') cancelRename(); + }, + [commitRename, cancelRename], + ); + + const handleDeleteClick = useCallback((id: string) => { + setDeleteConfirmId(id); + }, []); + + const confirmDelete = useCallback( + (id: string) => { + onDelete(id); + setDeleteConfirmId(null); + closeContextMenu(); + }, + [onDelete, closeContextMenu], + ); + + const handleArchiveToggle = useCallback( + (id: string, archived: boolean) => { + onArchive(id, archived); + closeContextMenu(); + }, + [onArchive, closeContextMenu], + ); + + const contextConv = contextMenu + ? conversations.find((c) => c.id === contextMenu.conversationId) + : null; + + function renderConversationItem(conv: Conversation): React.ReactElement { + const isActive = activeId === conv.id; + const isRenaming = renamingId === conv.id; + + return ( +
+ {isRenaming ? ( +
+ setRenameValue(e.target.value)} + onBlur={commitRename} + onKeyDown={handleRenameKeyDown} + className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none" + maxLength={255} + /> +
+ ) : ( - ))} + )}
-
+ ); + } + + return ( + <> + {/* Backdrop to close context menu */} + {contextMenu && ( +