nexus/ui/src/pages/PersonalAssistant.tsx
Nexus Dev 0bfec2a3c8 feat(33-01,33-02): memory service + sanitizer, personal assistant page
33-01: memory-sanitizer, assistant-memory service, REST routes, 17 tests
33-02: useNexusMode hook, PersonalAssistantPage, sidebar nav, route wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:03:09 +00:00

348 lines
13 KiB
TypeScript

// [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 (
<aside className="w-64 flex-shrink-0 border-r border-border bg-background flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">Conversations</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onNew}
disabled={isCreating}
title="New conversation"
>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{conversations.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">No conversations yet. Start one below.</p>
)}
{conversations.map((conv) => (
<button
key={conv.id}
type="button"
onClick={() => onSelect(conv.id)}
className={[
"w-full text-left px-4 py-2.5 text-sm transition-colors truncate",
selectedId === conv.id
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-accent/50",
].join(" ")}
>
{conv.title || "Untitled conversation"}
</button>
))}
</nav>
</aside>
);
}
// ─── 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 (
<div className={["flex gap-3 py-3", isUser ? "flex-row-reverse" : "flex-row"].join(" ")}>
{!isUser && (
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="h-4 w-4 text-primary" />
</div>
)}
<div
className={[
"max-w-[75%] rounded-lg px-3.5 py-2 text-sm leading-relaxed",
isUser
? "bg-primary text-primary-foreground rounded-br-sm"
: "bg-muted text-foreground rounded-bl-sm",
].join(" ")}
>
<p className="whitespace-pre-wrap">{content}</p>
{isStreaming && (
<span className="inline-block w-1 h-4 ml-0.5 bg-current animate-pulse align-middle" />
)}
</div>
</div>
);
}
// ─── 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<string | null>(routeConvId ?? null);
const [isCreating, setIsCreating] = useState(false);
const [inputValue, setInputValue] = useState("");
const [streamingContent, setStreamingContent] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
// Mode gate — wait for mode to load before redirecting
if (!modeLoading && !isAssistantEnabled) {
return <Navigate to="/dashboard" replace />;
}
if (!companyId) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Select a workspace to use the assistant.
</div>
);
}
return (
<div className="flex h-full min-h-0 -m-4 md:-m-6">
{/* Conversation list */}
<ConversationList
conversations={conversations}
selectedId={selectedConvId}
onSelect={setSelectedConvId}
onNew={handleNewConversation}
isCreating={isCreating}
/>
{/* Chat area */}
<div className="flex flex-col flex-1 min-w-0 h-full">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold text-foreground">Personal Assistant</h1>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="outline" size="sm" disabled className="gap-2 opacity-60 cursor-not-allowed">
<ArrowRight className="h-4 w-4" />
Turn into project
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Coming soon will create a project from this conversation</TooltipContent>
</Tooltip>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{!selectedConvId && !convsLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Bot className="h-10 w-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground max-w-xs">
Start a conversation with your personal AI assistant. It remembers context across sessions.
</p>
<Button onClick={handleNewConversation} disabled={isCreating} size="sm">
{isCreating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
New conversation
</Button>
</div>
)}
{selectedConvId && msgsLoading && (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{selectedConvId && !msgsLoading && messages.length === 0 && streamingContent === null && (
<div className="flex flex-col items-center justify-center h-full gap-2 text-center">
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
</div>
)}
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{streamingContent !== null && (
<MessageBubble message={null} streamingContent={streamingContent} />
)}
<div ref={messagesEndRef} />
</div>
{/* Input bar */}
{selectedConvId && (
<div className="px-6 py-4 border-t border-border shrink-0">
<div className="flex gap-3 items-end">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message your assistant… (Enter to send, Shift+Enter for newline)"
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring min-h-[40px] max-h-[160px]"
style={{ height: "auto" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}}
/>
<Button
onClick={handleSend}
disabled={!inputValue.trim() || isSending}
size="icon"
aria-label="Send message"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</div>
</div>
);
}