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 */}
{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 && (
)}
{/* New Chat Dialog (rendered at root level) */}
{showNewDialog && (
setShowNewDialog(false)}
onCreate={handleCreateSession}
/>
)}
);
}