feat(24-03): wire search, branch selector, export, scroll-to-message into ChatPanel

- Add scrollToMessageId/setScrollToMessageId to ChatPanelContext
- Add "Search chat messages" item to CommandPalette (dispatches nexus:open-chat-search)
- Integrate ChatSearchDialog, ChatBranchSelector, ChatBookmarkList into ChatPanel
- Add export buttons (Markdown) and bookmarks panel toggle in header
- Wire branch-on-edit: branchConversation called when editing message with subsequent replies
- Add scroll-to-message support in ChatMessageList via virtualizer.scrollToIndex
- Show GitBranch icon for branch conversations in ChatConversationList
This commit is contained in:
Nexus Dev 2026-04-01 22:39:21 +00:00
parent d20dce57ba
commit 77132b4351
5 changed files with 202 additions and 20 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; 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 { useChatConversations } from "../hooks/useChatConversations";
import { useChatPanel } from "../context/ChatPanelContext"; import { useChatPanel } from "../context/ChatPanelContext";
import { ChatConversationItem } from "./ChatConversationItem"; import { ChatConversationItem } from "./ChatConversationItem";
@ -169,16 +169,22 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) {
</div> </div>
) : ( ) : (
sorted.map((conversation) => ( sorted.map((conversation) => (
<ChatConversationItem <div key={conversation.id} className="relative">
key={conversation.id} {conversation.parentConversationId && (
conversation={conversation} <GitBranch className="absolute left-1 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none z-10" />
isActive={activeConversationId === conversation.id} )}
onSelect={setActiveConversationId} <div className={conversation.parentConversationId ? "pl-4" : undefined}>
onRename={handleRename} <ChatConversationItem
onPin={handlePin} conversation={conversation}
onArchive={handleArchive} isActive={activeConversationId === conversation.id}
onDelete={handleDeleteRequest} onSelect={setActiveConversationId}
/> onRename={handleRename}
onPin={handlePin}
onArchive={handleArchive}
onDelete={handleDeleteRequest}
/>
</div>
</div>
)) ))
)} )}

View file

@ -1,6 +1,7 @@
import { useRef, useEffect, useCallback, useState } from "react"; import { useRef, useEffect, useCallback, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useChatMessages } from "../hooks/useChatMessages"; import { useChatMessages } from "../hooks/useChatMessages";
import { useChatPanel } from "../context/ChatPanelContext";
import { ChatMessage } from "./ChatMessage"; import { ChatMessage } from "./ChatMessage";
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -18,6 +19,8 @@ interface ChatMessageListProps {
onRetry?: (messageId: string) => void; onRetry?: (messageId: string) => void;
onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void; onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void;
agentMap?: Map<string, { name: string; icon: string | null; role: AgentRole | null }>; agentMap?: Map<string, { name: string; icon: string | null; role: AgentRole | null }>;
onBookmark?: (messageId: string) => void;
bookmarkedMessageIds?: Set<string>;
} }
export function ChatMessageList({ export function ChatMessageList({
@ -31,8 +34,11 @@ export function ChatMessageList({
onRetry, onRetry,
onHandoff, onHandoff,
agentMap, agentMap,
onBookmark,
bookmarkedMessageIds,
}: ChatMessageListProps) { }: ChatMessageListProps) {
const { messages, isLoading } = useChatMessages(conversationId); const { messages, isLoading } = useChatMessages(conversationId);
const { scrollToMessageId, setScrollToMessageId } = useChatPanel();
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const [showJumpToBottom, setShowJumpToBottom] = useState(false); const [showJumpToBottom, setShowJumpToBottom] = useState(false);
@ -78,6 +84,19 @@ export function ChatMessageList({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamingContent, isStreaming]); }, [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 // Track scroll position for "jump to bottom" button
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = parentRef.current; const el = parentRef.current;
@ -156,6 +175,8 @@ export function ChatMessageList({
onEdit={onEdit} onEdit={onEdit}
onRetry={onRetry} onRetry={onRetry}
onHandoff={onHandoff} onHandoff={onHandoff}
onBookmark={onBookmark}
isBookmarked={msg.id ? (bookmarkedMessageIds?.has(msg.id) ?? false) : false}
/> />
</div> </div>
); );

View file

@ -1,5 +1,5 @@
import { useState, useMemo, useEffect, useCallback } from "react"; 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { useChatPanel } from "../context/ChatPanelContext"; import { useChatPanel } from "../context/ChatPanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@ -9,28 +9,41 @@ import { ChatConversationList } from "./ChatConversationList";
import { ChatMessageList } from "./ChatMessageList"; import { ChatMessageList } from "./ChatMessageList";
import { ChatAgentSelector } from "./ChatAgentSelector"; import { ChatAgentSelector } from "./ChatAgentSelector";
import { ChatStopButton } from "./ChatStopButton"; import { ChatStopButton } from "./ChatStopButton";
import { ChatSearchDialog } from "./ChatSearchDialog";
import { ChatBranchSelector } from "./ChatBranchSelector";
import { ChatBookmarkList } from "./ChatBookmarkList";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { chatApi } from "../api/chat"; import { chatApi } from "../api/chat";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { useChatMessages } from "../hooks/useChatMessages"; import { useChatMessages } from "../hooks/useChatMessages";
import { useStreamingChat } from "../hooks/useStreamingChat"; import { useStreamingChat } from "../hooks/useStreamingChat";
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
import { resolveAgentFromContent } from "../lib/slash-commands"; import { resolveAgentFromContent } from "../lib/slash-commands";
import type { AgentRole } from "@paperclipai/shared"; import type { AgentRole } from "@paperclipai/shared";
export function ChatPanel() { export function ChatPanel() {
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel(); const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId, setScrollToMessageId } = useChatPanel();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pushToast } = useToast(); const { pushToast } = useToast();
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null); const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useState(false);
const { messages } = useChatMessages(activeConversationId); const { messages } = useChatMessages(activeConversationId);
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
const brainstormerDefaultId = useBrainstormerDefault(); 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 // Auto-select brainstormer (general agent) for new conversations
useEffect(() => { useEffect(() => {
if (activeAgentId === null && brainstormerDefaultId !== null && !activeConversationId) { if (activeAgentId === null && brainstormerDefaultId !== null && !activeConversationId) {
@ -68,6 +81,51 @@ export function ChatPanel() {
// Resolve streaming agent identity // Resolve streaming agent identity
const streamingAgent = activeAgentId ? agentMap.get(activeAgentId) : undefined; 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<string>();
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) => { const handleSend = async (content: string) => {
if (!selectedCompanyId) return; 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) => { const handleEdit = async (messageId: string, newContent: string) => {
if (!activeConversationId) return; if (!activeConversationId) return;
await chatApi.editMessage(activeConversationId, messageId, newContent);
await chatApi.truncateMessagesAfter(activeConversationId, messageId); // Check if there are any messages after the edited message
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); const editedIdx = messages.findIndex((m) => m.id === messageId);
startStream(newContent, activeAgentId ?? undefined); 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, // 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) // Delete everything after the user message (includes the assistant message itself)
await chatApi.truncateMessagesAfter(activeConversationId, userMessageId); await chatApi.truncateMessagesAfter(activeConversationId, userMessageId);
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
queryClient.invalidateQueries({ queryKey: ["chat", "search"] });
// Re-stream using the actual user message content // Re-stream using the actual user message content
startStream(lastUserContent, activeAgentId ?? undefined); startStream(lastUserContent, activeAgentId ?? undefined);
@ -154,7 +239,33 @@ export function ChatPanel() {
{/* Header with agent selector */} {/* Header with agent selector */}
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]"> <div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
<span className="text-sm font-medium">Chat</span> <span className="text-sm font-medium">Chat</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{selectedCompanyId && activeConversationId && (
<>
{/* Bookmarks toggle */}
<Button
variant={bookmarksOpen ? "secondary" : "ghost"}
size="icon"
className="h-6 w-6"
onClick={() => setBookmarksOpen((v) => !v)}
aria-label="Bookmarks"
title="Bookmarks"
>
<Bookmark className="h-4 w-4" />
</Button>
{/* Export buttons */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleExport("markdown")}
aria-label="Export as Markdown"
title="Export as Markdown"
>
<Download className="h-4 w-4" />
</Button>
</>
)}
{selectedCompanyId && ( {selectedCompanyId && (
<ChatAgentSelector <ChatAgentSelector
companyId={selectedCompanyId} companyId={selectedCompanyId}
@ -190,6 +301,26 @@ export function ChatPanel() {
{/* Right column: message thread + stop button + input */} {/* Right column: message thread + stop button + input */}
<div className="flex flex-1 flex-col min-w-0"> <div className="flex flex-1 flex-col min-w-0">
{/* Bookmarks panel (shown when toggled) */}
{bookmarksOpen && selectedCompanyId && (
<div className="border-b border-border" style={{ maxHeight: 200, overflow: "hidden" }}>
<ChatBookmarkList
companyId={selectedCompanyId}
onNavigate={handleBookmarkNavigate}
/>
</div>
)}
{/* Branch selector */}
{activeConversationId && branches.length > 0 && (
<ChatBranchSelector
conversationId={activeConversationId}
branches={branches}
activeBranchId={activeConversationId}
onSelectBranch={setActiveConversationId}
/>
)}
{/* Message area */} {/* Message area */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{activeConversationId ? ( {activeConversationId ? (
@ -204,6 +335,8 @@ export function ChatPanel() {
onRetry={handleRetry} onRetry={handleRetry}
onHandoff={handleHandoff} onHandoff={handleHandoff}
agentMap={agentMap} agentMap={agentMap}
onBookmark={handleBookmark}
bookmarkedMessageIds={bookmarkedMessageIds}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-full p-3"> <div className="flex items-center justify-center h-full p-3">
@ -230,6 +363,14 @@ export function ChatPanel() {
</div> </div>
</div> </div>
</div> </div>
{/* Search dialog */}
<ChatSearchDialog
open={searchOpen}
onOpenChange={setSearchOpen}
companyId={selectedCompanyId}
onNavigate={handleSearchNavigate}
/>
</aside> </aside>
); );
} }

View file

@ -28,6 +28,7 @@ import {
History, History,
SquarePen, SquarePen,
Plus, Plus,
Search,
} from "lucide-react"; } from "lucide-react";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { agentUrl, projectUrl } from "../lib/utils"; import { agentUrl, projectUrl } from "../lib/utils";
@ -114,6 +115,16 @@ export function CommandPalette() {
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Actions"> <CommandGroup heading="Actions">
<CommandItem
value="search-chat"
onSelect={() => {
window.dispatchEvent(new CustomEvent("nexus:open-chat-search"));
setOpen(false);
}}
>
<Search className="mr-2 h-4 w-4" />
Search chat messages
</CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
setOpen(false); setOpen(false);

View file

@ -5,9 +5,11 @@ const STORAGE_KEY = "nexus:chat-panel-open";
interface ChatPanelContextValue { interface ChatPanelContextValue {
chatOpen: boolean; chatOpen: boolean;
activeConversationId: string | null; activeConversationId: string | null;
scrollToMessageId: string | null;
setChatOpen: (open: boolean) => void; setChatOpen: (open: boolean) => void;
toggleChat: () => void; toggleChat: () => void;
setActiveConversationId: (id: string | null) => void; setActiveConversationId: (id: string | null) => void;
setScrollToMessageId: (id: string | null) => void;
} }
const ChatPanelContext = createContext<ChatPanelContextValue | null>(null); const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
@ -32,6 +34,7 @@ function writePreference(open: boolean) {
export function ChatPanelProvider({ children }: { children: ReactNode }) { export function ChatPanelProvider({ children }: { children: ReactNode }) {
const [chatOpen, setChatOpenState] = useState(readPreference); const [chatOpen, setChatOpenState] = useState(readPreference);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null); const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const [scrollToMessageId, setScrollToMessageId] = useState<string | null>(null);
const setChatOpen = useCallback((open: boolean) => { const setChatOpen = useCallback((open: boolean) => {
setChatOpenState(open); setChatOpenState(open);
@ -48,7 +51,7 @@ export function ChatPanelProvider({ children }: { children: ReactNode }) {
return ( return (
<ChatPanelContext.Provider <ChatPanelContext.Provider
value={{ chatOpen, activeConversationId, setChatOpen, toggleChat, setActiveConversationId }} value={{ chatOpen, activeConversationId, scrollToMessageId, setChatOpen, toggleChat, setActiveConversationId, setScrollToMessageId }}
> >
{children} {children}
</ChatPanelContext.Provider> </ChatPanelContext.Provider>