// [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 && (
)}
);
}