import { useCallback, useEffect, useState } from 'react'; import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import type { Chat } from '@/api/types'; import { api } from '@/api/client'; import { Button } from '@/components/ui/button'; import { ChatInput } from '@/components/ChatInput'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { formatTokens } from '@/lib/format'; interface Props { sessionId: string; projectId: string; chats: Chat[]; onOpenChat: (chatId: string) => void; onSend: (content: string) => void; /** Create a chat and return its id. Used by slash-command handler. */ createChat: () => Promise<{ id: string }>; onReopenChat: (chatId: string) => Promise; onArchiveChat: (chatId: string) => Promise; onRenameChat: (chatId: string, name: string) => Promise; onDeleteChat: (chatId: string) => Promise; } function relTime(iso: string): string { const now = Date.now(); const t = Date.parse(iso); if (Number.isNaN(t)) return ''; const sec = Math.max(0, Math.floor((now - t) / 1000)); if (sec < 60) return `${sec}s ago`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const day = Math.floor(hr / 24); return `${day}d ago`; } interface ChatRowProps { chat: Chat; onClick: () => void; dimmed?: boolean; trailing?: React.ReactNode; actions?: React.ReactNode; renamingId: string | null; renameValue: string; setRenameValue: (s: string) => void; onFinishRename: () => void; onCancelRename: () => void; onContextStartRename: () => void; onContextArchive: () => void; onContextDelete: () => void; showContextMenu: boolean; } function ChatRow({ chat, onClick, dimmed, trailing, actions, renamingId, renameValue, setRenameValue, onFinishRename, onCancelRename, onContextStartRename, onContextArchive, onContextDelete, showContextMenu, }: ChatRowProps) { const meta: string[] = [relTime(chat.updated_at)]; if (chat.message_count !== undefined && chat.message_count > 0) { meta.push(`${chat.message_count} msg`); } const tokens = formatTokens(chat.effective_context_tokens); if (tokens) meta.push(tokens); const preview = chat.last_message_preview; const isRenaming = renamingId === chat.id; const inner = ( ); if (!showContextMenu) return inner; return ( {inner} Open Rename Archive Delete ); } export function SessionLandingPage({ chats, onOpenChat, onSend, projectId, createChat, onReopenChat, onArchiveChat, onRenameChat, onDeleteChat, }: Props) { const [composerValue, setComposerValue] = useState(''); const [chatId, setChatId] = useState(null); const [showArchived, setShowArchived] = useState(false); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [archiveConfirm, setArchiveConfirm] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const openChats = chats .filter((c) => c.status === 'open') .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); const archivedChats = chats .filter((c) => c.status === 'archived') .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); // Create a chat lazily on first send or slash command. const ensureChat = useCallback(async (): Promise => { if (chatId) return chatId; try { const chat = await createChat(); setChatId(chat.id); return chat.id; } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to create chat'); throw err; } }, [chatId, createChat]); async function handleSend() { const text = composerValue.trim(); if (!text) return; try { const cid = await ensureChat(); onSend(text); setComposerValue(''); } catch { // Error already surfaced via toast. } } // v2.3: slash-command dispatch on landing page. Creates a chat first if // one doesn't exist, then invokes the skill on that chat. const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { try { const cid = await ensureChat(); await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null); setComposerValue(''); } catch (err) { toast.error(err instanceof Error ? err.message : `/${skillName} failed`); } }, [ensureChat]); function startRename(chat: Chat) { setRenamingId(chat.id); setRenameValue(chat.name ?? ''); } async function finishRename() { if (renamingId && renameValue.trim()) { await onRenameChat(renamingId, renameValue.trim()); } setRenamingId(null); } // TODO: Landing page chat counts are a snapshot at mount. New messages in // visible chats won't update the per-row stats until next mount/navigation. return (
{openChats.length > 0 && (

Open chats

    {openChats.map((chat) => (
  • onOpenChat(chat.id)} renamingId={renamingId} renameValue={renameValue} setRenameValue={setRenameValue} onFinishRename={() => void finishRename()} onCancelRename={() => setRenamingId(null)} onContextStartRename={() => startRename(chat)} onContextArchive={() => setArchiveConfirm(chat)} onContextDelete={() => setDeleteConfirm(chat)} showContextMenu actions={ <> } />
  • ))}
)} {archivedChats.length > 0 && (
{showArchived && (
    {archivedChats.map((chat) => (
  • void onReopenChat(chat.id)} dimmed trailing={<>Restore} renamingId={null} renameValue="" setRenameValue={() => {}} onFinishRename={() => {}} onCancelRename={() => {}} onContextStartRename={() => {}} onContextArchive={() => {}} onContextDelete={() => {}} showContextMenu={false} />
  • ))}
)}
)} {openChats.length === 0 && archivedChats.length === 0 && (
No chats yet. Type below to start a conversation.
)}
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea. chatId is created lazily on first send/slash. */}
{ if (!open) setArchiveConfirm(null); }}> Archive chat? Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
{ if (!open) setDeleteConfirm(null); }}> Delete chat? Permanently delete{' '} {deleteConfirm?.name || '(unnamed)'} {' '}and all its messages. This cannot be undone.
); }