diff --git a/ui/src/components/ChatConversationList.tsx b/ui/src/components/ChatConversationList.tsx index a6e1ae51..d6df810d 100644 --- a/ui/src/components/ChatConversationList.tsx +++ b/ui/src/components/ChatConversationList.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Plus, Search, X } from "lucide-react"; +import { GitBranch, Plus, Search, X } from "lucide-react"; import { useChatConversations } from "../hooks/useChatConversations"; import { useChatPanel } from "../context/ChatPanelContext"; import { ChatConversationItem } from "./ChatConversationItem"; @@ -169,16 +169,22 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) { ) : ( sorted.map((conversation) => ( - +
+ {conversation.parentConversationId && ( + + )} +
+ +
+
)) )} diff --git a/ui/src/components/ChatMessageList.tsx b/ui/src/components/ChatMessageList.tsx index 13500295..53b2d2c9 100644 --- a/ui/src/components/ChatMessageList.tsx +++ b/ui/src/components/ChatMessageList.tsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useCallback, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useChatMessages } from "../hooks/useChatMessages"; +import { useChatPanel } from "../context/ChatPanelContext"; import { ChatMessage } from "./ChatMessage"; import { ArrowDown } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -18,6 +19,8 @@ interface ChatMessageListProps { onRetry?: (messageId: string) => void; onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void; agentMap?: Map; + onBookmark?: (messageId: string) => void; + bookmarkedMessageIds?: Set; } export function ChatMessageList({ @@ -31,8 +34,11 @@ export function ChatMessageList({ onRetry, onHandoff, agentMap, + onBookmark, + bookmarkedMessageIds, }: ChatMessageListProps) { const { messages, isLoading } = useChatMessages(conversationId); + const { scrollToMessageId, setScrollToMessageId } = useChatPanel(); const parentRef = useRef(null); const [showJumpToBottom, setShowJumpToBottom] = useState(false); @@ -78,6 +84,19 @@ export function ChatMessageList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamingContent, isStreaming]); + // Scroll to a specific message when scrollToMessageId is set + useEffect(() => { + if (!scrollToMessageId) return; + const index = displayMessages.findIndex((m) => m.id === scrollToMessageId); + if (index >= 0) { + virtualizer.scrollToIndex(index, { align: "center" }); + setScrollToMessageId(null); + } + // TODO: if message not in current page (infinite scroll), scroll is best-effort only. + // Future iteration: load pages until message found, then scroll. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollToMessageId]); + // Track scroll position for "jump to bottom" button const handleScroll = useCallback(() => { const el = parentRef.current; @@ -156,6 +175,8 @@ export function ChatMessageList({ onEdit={onEdit} onRetry={onRetry} onHandoff={onHandoff} + onBookmark={onBookmark} + isBookmarked={msg.id ? (bookmarkedMessageIds?.has(msg.id) ?? false) : false} /> ); diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index f03f8081..d137b92b 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect, useCallback } from "react"; -import { X } from "lucide-react"; +import { Bookmark, Download, X } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useChatPanel } from "../context/ChatPanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -9,28 +9,41 @@ 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 { 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 { resolveAgentFromContent } from "../lib/slash-commands"; import type { AgentRole } from "@paperclipai/shared"; export function ChatPanel() { - const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel(); + 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 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) { @@ -68,6 +81,51 @@ export function ChatPanel() { // 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; @@ -97,13 +155,39 @@ export function ChatPanel() { } }; - // Edit handler: update message, truncate after it, re-stream + // Edit handler: if editing a message that has subsequent responses, branch first const handleEdit = async (messageId: string, newContent: string) => { if (!activeConversationId) return; - await chatApi.editMessage(activeConversationId, messageId, newContent); - await chatApi.truncateMessagesAfter(activeConversationId, messageId); - queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - startStream(newContent, activeAgentId ?? undefined); + + // 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, @@ -140,6 +224,7 @@ export function ChatPanel() { // 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); @@ -154,7 +239,33 @@ export function ChatPanel() { {/* Header with agent selector */}
Chat -
+
+ {selectedCompanyId && activeConversationId && ( + <> + {/* Bookmarks toggle */} + + {/* Export buttons */} + + + )} {selectedCompanyId && ( + {/* Bookmarks panel (shown when toggled) */} + {bookmarksOpen && selectedCompanyId && ( +
+ +
+ )} + + {/* Branch selector */} + {activeConversationId && branches.length > 0 && ( + + )} + {/* Message area */}
{activeConversationId ? ( @@ -204,6 +335,8 @@ export function ChatPanel() { onRetry={handleRetry} onHandoff={handleHandoff} agentMap={agentMap} + onBookmark={handleBookmark} + bookmarkedMessageIds={bookmarkedMessageIds} /> ) : (
@@ -230,6 +363,14 @@ export function ChatPanel() {
+ + {/* Search dialog */} + ); } diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 9d84be52..ed1c66cd 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -28,6 +28,7 @@ import { History, SquarePen, Plus, + Search, } from "lucide-react"; import { Identity } from "./Identity"; import { agentUrl, projectUrl } from "../lib/utils"; @@ -114,6 +115,16 @@ export function CommandPalette() { No results found. + { + window.dispatchEvent(new CustomEvent("nexus:open-chat-search")); + setOpen(false); + }} + > + + Search chat messages + { setOpen(false); diff --git a/ui/src/context/ChatPanelContext.tsx b/ui/src/context/ChatPanelContext.tsx index 7ce3bdb3..bf3eed0c 100644 --- a/ui/src/context/ChatPanelContext.tsx +++ b/ui/src/context/ChatPanelContext.tsx @@ -5,9 +5,11 @@ const STORAGE_KEY = "nexus:chat-panel-open"; interface ChatPanelContextValue { chatOpen: boolean; activeConversationId: string | null; + scrollToMessageId: string | null; setChatOpen: (open: boolean) => void; toggleChat: () => void; setActiveConversationId: (id: string | null) => void; + setScrollToMessageId: (id: string | null) => void; } const ChatPanelContext = createContext(null); @@ -32,6 +34,7 @@ function writePreference(open: boolean) { export function ChatPanelProvider({ children }: { children: ReactNode }) { const [chatOpen, setChatOpenState] = useState(readPreference); const [activeConversationId, setActiveConversationId] = useState(null); + const [scrollToMessageId, setScrollToMessageId] = useState(null); const setChatOpen = useCallback((open: boolean) => { setChatOpenState(open); @@ -48,7 +51,7 @@ export function ChatPanelProvider({ children }: { children: ReactNode }) { return ( {children}