import { useState, useMemo, useEffect, useCallback } from "react"; import { Bookmark, Download, FileJson, X } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useChatPanel } from "../context/ChatPanelContext"; import { useCompany } from "../context/CompanyContext"; import { useToast } from "../context/ToastContext"; import { ChatInput } from "./ChatInput"; import { ChatConversationList } from "./ChatConversationList"; import { ChatMessageList } from "./ChatMessageList"; import { ChatAgentSelector } from "./ChatAgentSelector"; import { ChatStopButton } from "./ChatStopButton"; import { ChatSearchDialog } from "./ChatSearchDialog"; import { ChatBranchSelector } from "./ChatBranchSelector"; import { ChatBookmarkList } from "./ChatBookmarkList"; import { MobileChatView } from "./MobileChatView"; import { Button } from "@/components/ui/button"; import { chatApi } from "../api/chat"; import { agentsApi } from "../api/agents"; import { useChatMessages } from "../hooks/useChatMessages"; import { useStreamingChat } from "../hooks/useStreamingChat"; import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; import { useChatFileUpload } from "../hooks/useChatFileUpload"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { resolveAgentFromContent } from "../lib/slash-commands"; import type { AgentRole } from "@paperclipai/shared"; export function ChatPanel() { const isDesktop = useMediaQuery("(min-width: 768px)"); const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId, setScrollToMessageId } = useChatPanel(); const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const [isSending, setIsSending] = useState(false); const [activeAgentId, setActiveAgentId] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [bookmarksOpen, setBookmarksOpen] = useState(false); const { messages } = useChatMessages(activeConversationId); const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); const brainstormerDefaultId = useBrainstormerDefault(); // Listen for nexus:open-chat-search custom event (dispatched by CommandPalette) useEffect(() => { const handler = () => setSearchOpen(true); window.addEventListener("nexus:open-chat-search", handler); return () => window.removeEventListener("nexus:open-chat-search", handler); }, []); // Auto-select brainstormer (general agent) for new conversations useEffect(() => { if (activeAgentId === null && brainstormerDefaultId !== null && !activeConversationId) { setActiveAgentId(brainstormerDefaultId); } }, [activeAgentId, brainstormerDefaultId, activeConversationId]); // Handoff callback: call handoff API and invalidate messages cache const handleHandoff = useCallback(async (spec: { what: string; why: string; constraints: string; success: string }) => { if (!activeConversationId) return; try { await chatApi.handoffSpec(activeConversationId, spec, "pm"); queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); } catch { pushToast({ title: "Could not send to PM. Try again.", tone: "error" }); } }, [activeConversationId, queryClient, pushToast]); // Load agents for routing and identity const { data: agents = [], isLoading: agentsLoading } = useQuery({ queryKey: ["agents", selectedCompanyId], queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); // Build agent map for message identity bars const agentMap = useMemo(() => { const map = new Map(); for (const a of agents) { map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null }); } return map; }, [agents]); // Resolve streaming agent identity const streamingAgent = activeAgentId ? agentMap.get(activeAgentId) : undefined; // Fetch branches for active conversation const { data: branchesData } = useQuery({ queryKey: ["chat", "branches", activeConversationId], queryFn: () => chatApi.listBranches(activeConversationId!), enabled: !!activeConversationId, }); const branches = branchesData?.items ?? []; // Bookmark state for active conversation const { data: bookmarkData } = useChatBookmarks(selectedCompanyId, activeConversationId ?? undefined); const { toggleBookmark } = useToggleBookmark(); const bookmarkedMessageIds = useMemo(() => { const ids = new Set(); for (const b of bookmarkData?.items ?? []) { ids.add(b.message.id); } return ids; }, [bookmarkData]); // Search navigation handler const handleSearchNavigate = useCallback((conversationId: string, messageId: string) => { setActiveConversationId(conversationId); setScrollToMessageId(messageId); setSearchOpen(false); }, [setActiveConversationId, setScrollToMessageId]); // Bookmark navigation handler const handleBookmarkNavigate = useCallback((conversationId: string, messageId: string) => { setActiveConversationId(conversationId); setScrollToMessageId(messageId); setBookmarksOpen(false); }, [setActiveConversationId, setScrollToMessageId]); // Bookmark toggle handler const handleBookmark = useCallback((messageId: string) => { if (!activeConversationId) return; toggleBookmark({ conversationId: activeConversationId, messageId }); }, [activeConversationId, toggleBookmark]); // Export handler const handleExport = useCallback((format: "markdown" | "json") => { if (!activeConversationId) return; window.location.href = chatApi.exportConversation(activeConversationId, format); }, [activeConversationId]); const handleSend = async (content: string) => { if (!selectedCompanyId) return; // Resolve agent from slash command or @mention const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId); // Capture file IDs before clearing const fileIdsToAttach = [...completedFileIds]; setIsSending(true); try { if (!activeConversationId) { // Path 1: No active conversation -- create one, post user message, then stream const newConvo = await chatApi.createConversation(selectedCompanyId, { agentId: resolvedAgentId ?? undefined, }); setActiveConversationId(newConvo.id); const message = await chatApi.postMessage(newConvo.id, { role: "user", content }); // Attach any uploaded files to the message if (fileIdsToAttach.length > 0) { await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); clearCompleted(); queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); } queryClient.invalidateQueries({ queryKey: ["chat"] }); // Note: streaming starts on next render when activeConversationId is set // For now, the echo stream will be triggered by the new conversation } else { // Path 2: Active conversation -- post user message then stream const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); // Attach any uploaded files to the message if (fileIdsToAttach.length > 0) { await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); clearCompleted(); } queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); startStream(content, resolvedAgentId ?? undefined); } } finally { setIsSending(false); } }; // Edit handler: if editing a message that has subsequent responses, branch first const handleEdit = async (messageId: string, newContent: string) => { if (!activeConversationId) return; // Check if there are any messages after the edited message const editedIdx = messages.findIndex((m) => m.id === messageId); const hasSubsequentMessages = editedIdx >= 0 && editedIdx < messages.length - 1; if (hasSubsequentMessages) { // Branch the conversation from this message first (CHAT-14) try { const newConv = await chatApi.branchConversation(activeConversationId, messageId); setActiveConversationId(newConv.id); // Invalidate branches list so branch selector updates queryClient.invalidateQueries({ queryKey: ["chat", "branches"] }); queryClient.invalidateQueries({ queryKey: ["chat"] }); // Edit the message on the new branch await chatApi.editMessage(newConv.id, messageId, newContent); await chatApi.truncateMessagesAfter(newConv.id, messageId); queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConv.id] }); queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); startStream(newContent, activeAgentId ?? undefined); } catch { pushToast({ title: "Could not create branch. Try again.", tone: "error" }); } } else { // No subsequent messages — edit in place await chatApi.editMessage(activeConversationId, messageId, newContent); await chatApi.truncateMessagesAfter(activeConversationId, messageId); queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); startStream(newContent, activeAgentId ?? undefined); } }; // Retry handler: find the last user message before the assistant message, // delete the assistant message and everything after it, then re-stream // with the actual prior user message content (not hardcoded text). const handleRetry = async (assistantMessageId: string) => { if (!activeConversationId || !messages) return; // Find the assistant message index in the messages array const assistantIdx = messages.findIndex((m) => m.id === assistantMessageId); if (assistantIdx < 0) return; // Find the last user message before this assistant message let lastUserContent = ""; for (let i = assistantIdx - 1; i >= 0; i--) { if (messages[i]!.role === "user") { lastUserContent = messages[i]!.content; break; } } if (!lastUserContent) return; // No prior user message found; nothing to retry // Truncate messages after the user message (this deletes the assistant msg + everything after) // First, find the user message to truncate after let userMessageId = ""; for (let i = assistantIdx - 1; i >= 0; i--) { if (messages[i]!.role === "user") { userMessageId = messages[i]!.id; break; } } if (!userMessageId) return; // Delete everything after the user message (includes the assistant message itself) await chatApi.truncateMessagesAfter(activeConversationId, userMessageId); queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); // Re-stream using the actual user message content startStream(lastUserContent, activeAgentId ?? undefined); }; // On mobile, render the full-screen MobileChatView instead of the desktop slide-in panel if (!isDesktop) { return ; } return ( ); }