// [nexus] Personal Assistant page — full-page chat for Personal AI mode import { useState, useEffect, useRef, useCallback } from "react"; import { Navigate, useParams } from "@/lib/router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react"; import { useNexusMode } from "../hooks/useNexusMode"; import { useCompany } from "../context/CompanyContext"; import { chatApi } from "../api/chat"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import type { ChatConversationListItem, ChatMessage } from "@paperclipai/shared"; // ─── Conversation list panel ───────────────────────────────────────────────── interface ConversationListProps { conversations: ChatConversationListItem[]; selectedId: string | null; onSelect: (id: string) => void; onNew: () => void; isCreating: boolean; } function ConversationList({ conversations, selectedId, onSelect, onNew, isCreating }: ConversationListProps) { return ( ); } // ─── Message bubble ─────────────────────────────────────────────────────────── function MessageBubble({ message, streamingContent }: { message: ChatMessage | null; streamingContent?: string }) { const isUser = message?.role === "user"; const content = message ? message.content : (streamingContent ?? ""); const isStreaming = !message && streamingContent !== undefined; return (
{!isUser && (
)}

{content}

{isStreaming && ( )}
); } // ─── Main page ──────────────────────────────────────────────────────────────── export function PersonalAssistant() { const { isAssistantEnabled, isLoading: modeLoading } = useNexusMode(); const { selectedCompany } = useCompany(); const { conversationId: routeConvId } = useParams<{ conversationId?: string }>(); const queryClient = useQueryClient(); const [selectedConvId, setSelectedConvId] = useState(routeConvId ?? null); const [isCreating, setIsCreating] = useState(false); const [inputValue, setInputValue] = useState(""); const [streamingContent, setStreamingContent] = useState(null); const [isSending, setIsSending] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); const abortRef = useRef(null); const companyId = selectedCompany?.id ?? null; // Fetch conversation list const { data: convData, isLoading: convsLoading } = useQuery({ queryKey: ["assistant", "conversations", companyId], queryFn: () => chatApi.listConversations(companyId!, { limit: 50 }), enabled: !!companyId, staleTime: 30_000, }); const conversations: ChatConversationListItem[] = convData?.items ?? []; // Auto-select first conversation if none selected useEffect(() => { if (!selectedConvId && conversations.length > 0) { setSelectedConvId(conversations[0]!.id); } }, [conversations, selectedConvId]); // Fetch messages for selected conversation const { data: msgData, isLoading: msgsLoading } = useQuery({ queryKey: ["assistant", "messages", selectedConvId], queryFn: () => chatApi.listMessages(selectedConvId!), enabled: !!selectedConvId, staleTime: 10_000, }); const messages: ChatMessage[] = msgData?.items ?? []; // Scroll to bottom when messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamingContent]); const handleNewConversation = useCallback(async () => { if (!companyId || isCreating) return; setIsCreating(true); try { const conv = await chatApi.createConversation(companyId, { title: "New conversation", }); queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] }); setSelectedConvId(conv.id); } finally { setIsCreating(false); } }, [companyId, isCreating, queryClient]); const handleSend = useCallback(async () => { const text = inputValue.trim(); if (!text || !selectedConvId || isSending) return; setInputValue(""); setIsSending(true); setStreamingContent(""); abortRef.current?.abort(); const abort = new AbortController(); abortRef.current = abort; try { // Optimistically add user message to cache queryClient.setQueryData( ["assistant", "messages", selectedConvId], (old: { items: ChatMessage[]; hasMore?: boolean } | undefined) => ({ items: [ ...(old?.items ?? []), { id: `tmp-${Date.now()}`, conversationId: selectedConvId, role: "user" as const, content: text, agentId: null, messageType: null, createdAt: new Date().toISOString(), updatedAt: null, } satisfies ChatMessage, ], hasMore: old?.hasMore ?? false, }), ); await chatApi.postMessageAndStream( selectedConvId, { content: text }, { onToken: (token: string) => { setStreamingContent((prev) => (prev ?? "") + token); }, onDone: () => { setStreamingContent(null); queryClient.invalidateQueries({ queryKey: ["assistant", "messages", selectedConvId] }); queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] }); }, onError: () => { setStreamingContent(null); queryClient.invalidateQueries({ queryKey: ["assistant", "messages", selectedConvId] }); }, }, abort.signal, ); } catch { setStreamingContent(null); } finally { setIsSending(false); } }, [inputValue, selectedConvId, isSending, queryClient, companyId]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend], ); // Mode gate — wait for mode to load before redirecting if (!modeLoading && !isAssistantEnabled) { return ; } if (!companyId) { return (
Select a workspace to use the assistant.
); } return (
{/* Conversation list */} {/* Chat area */}
{/* Header */}

Personal Assistant

Coming soon — will create a project from this conversation
{/* Messages */}
{!selectedConvId && !convsLoading && (

Start a conversation with your personal AI assistant. It remembers context across sessions.

)} {selectedConvId && msgsLoading && (
)} {selectedConvId && !msgsLoading && messages.length === 0 && streamingContent === null && (

Send a message to start this conversation.

)} {messages.map((msg) => ( ))} {streamingContent !== null && ( )}
{/* Input bar */} {selectedConvId && (