import { useCallback, useEffect, useRef, useState } from "react"; import { MessageSquare, Send, Plus, Search, Trash2, Archive, ChevronLeft, Bot, } from "lucide-react"; import { useChat } from "../hooks/useChat"; import { useAgents } from "../hooks/useAgents"; import { useViewportMode } from "./Header"; import type { Agent } from "../api"; export interface ChatViewProps { projectId?: string; addToast: (msg: string, type?: "success" | "error") => void; } function getAgentLabel(agent: Agent): string { const base = agent.name?.trim() || agent.id; return `${base} (${agent.role})`; } function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } interface NewChatDialogProps { agents: Agent[]; onClose: () => void; onCreate: (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => void; } function NewChatDialog({ agents, onClose, onCreate }: NewChatDialogProps) { const [agentId, setAgentId] = useState(agents[0]?.id ?? ""); const [title, setTitle] = useState(""); const [modelProvider, setModelProvider] = useState(""); const [modelId, setModelId] = useState(""); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!agentId) return; onCreate({ agentId, title: title || undefined, modelProvider: modelProvider || undefined, modelId: modelId || undefined }); }; return (
e.stopPropagation()}>

New Chat

); } export function ChatView({ projectId, addToast }: ChatViewProps) { const { agents } = useAgents(projectId); const { sessions, activeSession, sessionsLoading, messages, messagesLoading, isStreaming, streamingText, streamingThinking, selectSession, createSession, archiveSession, deleteSession, sendMessage, searchQuery, setSearchQuery, filteredSessions, } = useChat(projectId); const [showNewDialog, setShowNewDialog] = useState(false); const [messageInput, setMessageInput] = useState(""); const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null); const [confirmDelete, setConfirmDelete] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(true); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const mode = useViewportMode(); const isMobile = mode === "mobile"; // Scroll to bottom on new messages or streaming useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamingText]); // Close context menu on outside click useEffect(() => { const handleClick = () => setContextMenu(null); if (contextMenu) { document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); } }, [contextMenu]); // Handle create session const handleCreateSession = useCallback( async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => { try { await createSession(input); setShowNewDialog(false); // On mobile, hide sidebar after selecting if (isMobile) setSidebarVisible(false); } catch { addToast("Failed to create chat session", "error"); } }, [createSession, addToast, isMobile], ); // Handle send message const handleSend = useCallback(async () => { const trimmed = messageInput.trim(); if (!trimmed || isStreaming || !activeSession) return; setMessageInput(""); try { await sendMessage(trimmed); } catch { addToast("Failed to send message", "error"); } }, [messageInput, isStreaming, activeSession, sendMessage, addToast]); // Handle input key down const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSend(); } }, [handleSend], ); // Handle textarea resize const handleInputChange = useCallback((e: React.ChangeEvent) => { const textarea = e.target; setMessageInput(textarea.value); textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; }, []); // Handle archive const handleArchive = useCallback( async (id: string) => { setContextMenu(null); try { await archiveSession(id); addToast("Conversation archived", "success"); } catch { addToast("Failed to archive conversation", "error"); } }, [archiveSession, addToast], ); // Handle delete const handleDelete = useCallback( async (id: string) => { setConfirmDelete(null); setContextMenu(null); try { await deleteSession(id); addToast("Conversation deleted", "success"); } catch { addToast("Failed to delete conversation", "error"); } }, [deleteSession, addToast], ); // Handle session click const handleSessionClick = useCallback( (id: string) => { selectSession(id); if (isMobile) setSidebarVisible(false); }, [selectSession, isMobile], ); // Handle back to sidebar (mobile) const handleBack = useCallback(() => { selectSession(""); setSidebarVisible(true); }, [selectSession]); // Render empty state (no active session) const renderEmptyState = () => { if (showNewDialog) { return ( setShowNewDialog(false)} onCreate={handleCreateSession} /> ); } return (

Start a new conversation

); }; return (
{/* Sidebar */}
setSearchQuery(e.target.value)} data-testid="chat-search-input" />
{sessionsLoading ? (
Loading...
) : filteredSessions.length === 0 ? (
No conversations yet
) : ( filteredSessions.map((session) => (
handleSessionClick(session.id)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY }); }} data-testid={`chat-session-${session.id}`} >
{session.title || "Untitled"}
{session.lastMessagePreview || "No messages"}
{session.agentId.slice(0, 30)} {session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}
)) )}
{/* Context Menu */} {contextMenu && (
e.stopPropagation()} >
)} {/* Confirm Delete Dialog */} {confirmDelete && (
setConfirmDelete(null)}>
e.stopPropagation()}>

Delete Conversation?

This action cannot be undone. All messages in this conversation will be permanently deleted.

)} {/* Thread */}
{/* Header */}
{isMobile && ( )} {activeSession?.title || activeSession?.agentId || "Chat"}
{/* Messages */}
{messagesLoading ? (
Loading messages...
) : messages.length === 0 && !activeSession ? ( renderEmptyState() ) : messages.length === 0 && activeSession ? (
No messages yet. Start the conversation!
) : ( <> {messages.map((message) => (
{message.role === "assistant" && (
Assistant
)}
{message.content}
{message.thinkingOutput && (
Thinking
{message.thinkingOutput}
)}
{formatRelativeTime(message.createdAt)}
))} {isStreaming && streamingText && (
Assistant
{streamingText}
{streamingThinking && (
Thinking
{streamingThinking}
)}
)} )}
{/* Input */} {activeSession && (