diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index 3e1042e0..8ea27c2c 100644 --- a/apps/web/app/new/page.tsx +++ b/apps/web/app/new/page.tsx @@ -1,7 +1,7 @@ "use client" import { Header } from "@/components/new/header" -import { ChatSidebar } from "@/components/new/chat-sidebar" +import { ChatSidebar } from "@/components/chat" import { AnimatePresence } from "motion/react" import { MemoriesGrid } from "@/components/new/memories-grid" diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx new file mode 100644 index 00000000..0c99b2b6 --- /dev/null +++ b/apps/web/components/chat/index.tsx @@ -0,0 +1,309 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { motion, AnimatePresence } from "motion/react" +import { useChat } from "@ai-sdk/react" +import { DefaultChatTransport } from "ai" +import NovaOrb from "@/components/nova/nova-orb" +import { Button } from "@ui/components/button" +import { + HistoryIcon, + PanelRightCloseIcon, + SearchIcon, + SquarePenIcon, +} from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/utils/fonts" +import ChatInput from "./input" +import ChatModelSelector from "./model-selector" +import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo" +import { useProject, usePersistentChat } from "@/stores" +import type { ModelId } from "@/lib/models" +import { SuperLoader } from "../superloader" +import { UserMessage } from "./message/user-message" +import { AgentMessage } from "./message/agent-message" + +export function ChatSidebar() { + const [input, setInput] = useState("") + const [isChatOpen, setIsChatOpen] = useState(true) + const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") + const [copiedMessageId, setCopiedMessageId] = useState(null) + const [hoveredMessageId, setHoveredMessageId] = useState(null) + const [messageFeedback, setMessageFeedback] = useState< + Record + >({}) + const [expandedMemories, setExpandedMemories] = useState(null) + const { selectedProject } = useProject() + const { setCurrentChatId } = usePersistentChat() + + const { messages, sendMessage, status, setMessages, stop } = useChat({ + transport: new DefaultChatTransport({ + api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`, + credentials: "include", + body: { + metadata: { + projectId: selectedProject, + model: selectedModel, + }, + }, + }), + maxSteps: 10, + }) + + const handleSend = () => { + if (!input.trim() || status === "submitted" || status === "streaming") + return + sendMessage({ text: input }) + setInput("") + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const toggleChat = () => { + setIsChatOpen(!isChatOpen) + } + + const handleCopyMessage = useCallback((messageId: string, text: string) => { + navigator.clipboard.writeText(text) + setCopiedMessageId(messageId) + setTimeout(() => setCopiedMessageId(null), 2000) + }, []) + + const handleLikeMessage = useCallback((messageId: string) => { + setMessageFeedback((prev) => ({ + ...prev, + [messageId]: prev[messageId] === "like" ? null : "like", + })) + }, []) + + const handleDislikeMessage = useCallback((messageId: string) => { + setMessageFeedback((prev) => ({ + ...prev, + [messageId]: prev[messageId] === "dislike" ? null : "dislike", + })) + }, []) + + const handleToggleMemories = useCallback((messageId: string) => { + setExpandedMemories((prev) => (prev === messageId ? null : messageId)) + }, []) + + const handleNewChat = useCallback(() => { + console.log("handleNewChat") + const newId = crypto.randomUUID() + setCurrentChatId(newId) + setMessages([]) + setInput("") + }, [setCurrentChatId, setMessages]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key.toLowerCase() === "t" && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + isChatOpen && + document.activeElement?.tagName !== "INPUT" && + document.activeElement?.tagName !== "TEXTAREA" + ) { + e.preventDefault() + handleNewChat() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [isChatOpen, handleNewChat]) + + return ( + + {!isChatOpen ? ( + + + + Chat with Nova + + + ) : ( + +
+ +
+ + + + + +
+
+
+ {messages.length === 0 && } +
0 + ? "flex flex-col space-y-3 min-h-full justify-end" + : "", + )} + > + {messages.map((message, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions +
+ message.role === "assistant" && + setHoveredMessageId(message.id) + } + onMouseLeave={() => + message.role === "assistant" && setHoveredMessageId(null) + } + > + {message.role === "user" ? ( + + ) : ( + + )} +
+ ))} + {(status === "submitted" || status === "streaming") && + messages[messages.length - 1]?.role === "user" && ( +
+ +
+ )} +
+
+ +
+ setInput(e.target.value)} + onSend={handleSend} + onStop={stop} + onKeyDown={handleKeyDown} + isResponding={status === "submitted" || status === "streaming"} + /> +
+
+ )} +
+ ) +} + +function ChatEmptyStatePlaceholder() { + const suggestions = [ + "Show me all content related to Supermemory.", + "Summarize the key ideas from My Gita.", + "Which memories connect design and AI?", + "What are the main themes across my memories?", + ] + + return ( +
+
+ + +
+
+

Ask me anything about your memories...

+
+ {suggestions.map((suggestion) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/web/components/chat/input.tsx b/apps/web/components/chat/input.tsx new file mode 100644 index 00000000..2d307062 --- /dev/null +++ b/apps/web/components/chat/input.tsx @@ -0,0 +1,116 @@ +"use client" + +import { ArrowUpIcon, ChevronUpIcon, SquareIcon } from "lucide-react" +import NovaOrb from "../nova/nova-orb" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/utils/fonts" +import { useRef, useState } from "react" + +interface ChatInputProps { + value: string + onChange: (e: React.ChangeEvent) => void + onSend: () => void + onStop: () => void + onKeyDown?: (e: React.KeyboardEvent) => void + isResponding?: boolean +} + +export default function ChatInput({ + value, + onChange, + onSend, + onStop, + onKeyDown, + isResponding = false, +}: ChatInputProps) { + const [isMultiline, setIsMultiline] = useState(false) + const textareaRef = useRef(null) + + const handleChange = (e: React.ChangeEvent) => { + onChange(e) + + const textarea = e.target + textarea.style.height = "auto" + + // Set height based on scrollHeight, with a max of ~96px (4-5 lines) + const newHeight = Math.min(textarea.scrollHeight, 96) + textarea.style.height = `${newHeight}px` + + setIsMultiline(textarea.scrollHeight > 52) + } + + return ( +
+
+
+ +

+ Waiting for input... +

+
+ +
+
+