- MobileChatView: full-screen mobile chat using 100dvh, back button, safe-area input - ChatPanel: conditionally renders MobileChatView on mobile via useMediaQuery - ChatConversationList: wraps ScrollArea in PullToRefresh for mobile - ChatInput: pb-[env(safe-area-inset-bottom)] padding + 44px Send button touch target - ChatConversationItem: min-h-[48px] touch target per UI-SPEC
414 lines
17 KiB
TypeScript
414 lines
17 KiB
TypeScript
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<string | null>(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<string, { name: string; icon: string | null; role: AgentRole | null }>();
|
|
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<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) => {
|
|
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 <MobileChatView />;
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
aria-label="Chat"
|
|
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
|
|
style={{ width: chatOpen ? 380 : 0 }}
|
|
>
|
|
{/* Header with agent selector */}
|
|
<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>
|
|
<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>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => handleExport("json")}
|
|
aria-label="Export as JSON"
|
|
title="Export as JSON"
|
|
>
|
|
<FileJson className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{selectedCompanyId && (
|
|
<ChatAgentSelector
|
|
companyId={selectedCompanyId}
|
|
conversationId={activeConversationId}
|
|
agentId={activeAgentId}
|
|
onAgentChange={setActiveAgentId}
|
|
/>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setChatOpen(false)}
|
|
aria-label="Close chat"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Two-column layout */}
|
|
<div className="flex flex-1 min-h-0 min-w-[380px]">
|
|
{/* Left column: conversation list */}
|
|
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
|
|
{selectedCompanyId ? (
|
|
<ChatConversationList companyId={selectedCompanyId} />
|
|
) : (
|
|
<div className="p-3 text-center text-xs text-muted-foreground">
|
|
No workspace selected
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right column: message thread + stop button + input */}
|
|
<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 */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{activeConversationId ? (
|
|
<ChatMessageList
|
|
conversationId={activeConversationId}
|
|
streamingContent={streamingContent}
|
|
isStreaming={isStreaming}
|
|
streamingAgentName={streamingAgent?.name ?? null}
|
|
streamingAgentIcon={streamingAgent?.icon ?? null}
|
|
streamingAgentRole={streamingAgent?.role ?? null}
|
|
onEdit={handleEdit}
|
|
onRetry={handleRetry}
|
|
onHandoff={handleHandoff}
|
|
agentMap={agentMap}
|
|
onBookmark={handleBookmark}
|
|
bookmarkedMessageIds={bookmarkedMessageIds}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full p-3">
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Send a message to start this conversation.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stop button (shown during streaming) */}
|
|
{isStreaming && <ChatStopButton onStop={stop} />}
|
|
|
|
{/* Input area */}
|
|
<div className="border-t border-border px-3 py-2">
|
|
<ChatInput
|
|
onSend={handleSend}
|
|
isSubmitting={isSending}
|
|
disabled={isStreaming}
|
|
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
|
agents={agents}
|
|
agentsLoading={agentsLoading}
|
|
pendingFiles={pendingFiles}
|
|
onRemoveFile={removeFile}
|
|
onFilesPicked={(files) => files.forEach(addFile)}
|
|
enableVoiceInput={true}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search dialog */}
|
|
<ChatSearchDialog
|
|
open={searchOpen}
|
|
onOpenChange={setSearchOpen}
|
|
companyId={selectedCompanyId}
|
|
onNavigate={handleSearchNavigate}
|
|
/>
|
|
</aside>
|
|
);
|
|
}
|