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>
348 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|