supermemory/apps/web/components/chat/index.tsx
Sunny ecd43d7131 Merge remote-tracking branch 'upstream/main' into fix/dynamic-chat-suggestions
# Conflicts:
#	apps/web/components/chat/index.tsx
2026-05-10 22:57:59 -07:00

1206 lines
35 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/react"
import { motion } 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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@ui/components/sheet"
import { ScrollArea } from "@ui/components/scroll-area"
import {
ArrowLeft,
Check,
ChevronDownIcon,
HistoryIcon,
Plus,
SearchIcon,
SquarePenIcon,
Trash2,
XIcon,
} from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { getNovaChatErrorCopy } from "@/lib/chat-stream-error"
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { modelNames, type ModelId } from "@/lib/models"
import { SuperLoader } from "../superloader"
import { UserMessage } from "./message/user-message"
import { AgentMessage } from "./message/agent-message"
import { ChatGraphContextRail } from "./chat-graph-context-rail"
import { ChainOfThought } from "./input/chain-of-thought"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import { generateId } from "@lib/generate-id"
import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
// ---------------------------------------------------------------------------
// Suggestions: constants, types, pure helpers, and hook
//
// The chat empty state previously hardcoded content-specific prompts like
// "Summarize the key ideas from My Gita" which a new user won't own. This
// module replaces them with prompts derived from the user's actual memories,
// falling back gracefully when the user has none or the fetch fails.
//
// The pure helpers and the hook below are intentionally decoupled from the
// sidebar UI so they can be reused when the chat interface is refactored.
// ---------------------------------------------------------------------------
/** Generic fallback - never content-specific so it works for any user. */
const DEFAULT_SUGGESTIONS = [
"What are the main themes across my memories?",
"Summarize the most important things I've saved.",
"Which memories are most relevant to each other?",
"What topics appear most often in my memories?",
]
/** Shown when the user has zero memories. Onboarding-oriented. */
const ONBOARDING_SUGGESTIONS = [
"How do I add my first memory?",
"What kinds of content can I save here?",
"How does search work across my memories?",
"What can you help me do once I've added memories?",
]
/** Minimal shape - only the fields the suggestion logic actually reads. */
type Memory = {
id: string
title?: string
content?: string
}
/** Extract a short readable label from a memory, or null if none is usable. */
function extractMemoryLabel(memory: Memory): string | null {
if (memory.title?.trim()) return memory.title.trim()
if (memory.content?.trim()) {
const excerpt = memory.content.trim().split(/[.\n]/)[0]?.trim() ?? ""
if (excerpt.length > 4) return excerpt.slice(0, 60)
}
return null
}
/**
* Produce 4 contextual suggestions from the user's memories.
* Pure function, deterministic, safe to unit-test in isolation.
*/
export function deriveSuggestionsFromMemories(memories: Memory[]): string[] {
const labels = memories
.map(extractMemoryLabel)
.filter((l): l is string => l !== null)
.filter(
(label, i, arr) =>
arr.findIndex((l) => l.toLowerCase() === label.toLowerCase()) === i,
)
.slice(0, 6)
if (labels.length === 0) return DEFAULT_SUGGESTIONS
const t = (i: number) => labels[i % labels.length]
return [
`What are the key ideas in "${t(0)}"?`,
labels.length >= 2
? `How does "${t(0)}" connect to "${t(1)}"?`
: `Summarize everything I've saved about ${t(0)}`,
labels.length >= 3
? `What patterns appear across "${t(1)}" and "${t(2)}"?`
: `What can you find related to ${t(0)} in my memories?`,
labels.length >= 4
? `Compare the main themes in "${t(2)}" and "${t(3)}"`
: `What questions should I be asking about ${t(0)}?`,
]
}
/**
* Fetch the user's memories for the current project and derive prompt
* suggestions from them. Fallback chain:
* derived prompts -> ONBOARDING_SUGGESTIONS (0 memories) -> DEFAULT_SUGGESTIONS (error)
*/
function useDynamicSuggestions(selectedProject: string | null): {
suggestions: string[]
isLoading: boolean
} {
const [suggestions, setSuggestions] = useState<string[]>(DEFAULT_SUGGESTIONS)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let cancelled = false
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
async function load() {
setIsLoading(true)
try {
const body: Record<string, unknown> = { limit: 20 }
if (selectedProject) body.containerTags = [selectedProject]
const res = await fetch(`${backendUrl}/v3/documents/documents`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok || cancelled) return
const data = await res.json()
const memories: Memory[] = data.documents ?? []
if (cancelled) return
setSuggestions(
memories.length === 0
? ONBOARDING_SUGGESTIONS
: deriveSuggestionsFromMemories(memories),
)
} catch {
if (!cancelled) setSuggestions(DEFAULT_SUGGESTIONS)
} finally {
if (!cancelled) setIsLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [selectedProject])
return { suggestions, isLoading }
}
// ---------------------------------------------------------------------------
function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = DEFAULT_SUGGESTIONS,
isLoadingSuggestions = false,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
isLoadingSuggestions?: boolean
}) {
return (
<div
id="chat-empty-state"
className="flex flex-col items-center justify-center h-full"
>
<div className="relative size-32">
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16" />
<LogoBgGradient className="size-full" />
</div>
<div className="gap-3 flex flex-col items-center justify-center">
<p>Ask me anything about your memories…</p>
<div
className={cn(
dmSansClassName(),
"flex flex-col gap-2 justify-center items-center",
)}
>
{isLoadingSuggestions
? Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-10 w-[300px] animate-pulse rounded-full border border-[#2261CA33] bg-[#041127]"
/>
))
: suggestions.map((suggestion) => (
<Button
key={suggestion}
variant="default"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
<span className="text-[#267BF1] text-[12px] truncate">
{suggestion}
</span>
</Button>
))}
</div>
</div>
</div>
)
}
export function ChatLaunchFab({
onOpen,
isMobile,
}: {
onOpen: () => void
isMobile: boolean
}) {
return (
<motion.div
className={cn(
"flex items-start justify-start pointer-events-none",
isMobile
? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
: "fixed z-20 top-24 right-4 md:right-6",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
>
<motion.button
type="button"
onClick={onOpen}
className={cn(
"pointer-events-auto flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border text-white cursor-pointer whitespace-nowrap",
isMobile
? "gap-2.5 px-5 py-3 text-[15px] border-[#1E2128] shadow-[0_8px_32px_rgba(0,0,0,0.5),0_2px_8px_rgba(0,0,0,0.3)]"
: "border-[#17181A] shadow-lg",
)}
style={{
background: isMobile
? "linear-gradient(135deg, #12161C 0%, #0A0D12 100%)"
: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<NovaOrb size={isMobile ? 26 : 24} className="blur-[0.6px]! z-10" />
<span className={cn(isMobile && "font-medium")}>Chat with Nova</span>
</motion.button>
</motion.div>
)
}
export function ChatSidebar({
isChatOpen,
setIsChatOpen,
queuedMessage,
onConsumeQueuedMessage,
queuedMessageSource = "highlight",
initialSelectedModel = null,
emptyStateSuggestions,
layout = "sidebar",
}: {
isChatOpen: boolean
setIsChatOpen: (open: boolean) => void
queuedMessage?: string | null
onConsumeQueuedMessage?: () => void
queuedMessageSource?: "highlight" | "home"
initialSelectedModel?: ModelId | null
emptyStateSuggestions?: string[]
layout?: "sidebar" | "page"
}) {
const isMobile = useIsMobile()
const isPageDesktop = layout === "page" && !isMobile
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>(
initialSelectedModel ?? "claude-sonnet-4.6",
)
const selectedModelRef = useRef(selectedModel)
selectedModelRef.current = selectedModel
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
const [messageFeedback, setMessageFeedback] = useState<
Record<string, "like" | "dislike" | null>
>({})
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
const [isInputExpanded, setIsInputExpanded] = useState(false)
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
const [heightOffset, setHeightOffset] = useState(95)
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [threads, setThreads] = useState<
Array<{ id: string; title: string; createdAt: string; updatedAt: string }>
>([])
const [isLoadingThreads, setIsLoadingThreads] = useState(false)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(
null,
)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const sentQueuedMessageRef = useRef<string | null>(null)
const { selectedProject } = useProject()
const { allProjects } = useContainerTags()
const selectedProjectRef = useRef(selectedProject)
selectedProjectRef.current = selectedProject
const chatSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const { viewMode } = useViewMode()
const { user: _user } = useAuth()
const [threadId, setThreadId] = useQueryState("thread", threadParam)
const [fallbackChatId, setFallbackChatId] = useState(() => generateId())
const currentChatId = threadId ?? fallbackChatId
const chatIdRef = useRef(currentChatId)
chatIdRef.current = currentChatId
const _setCurrentChatId = useCallback(
(id: string) => setThreadId(id),
[setThreadId],
)
const chatApiBase =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const chatTransport = useMemo(
() =>
new DefaultChatTransport({
api: `${chatApiBase}/chat`,
credentials: "include",
prepareSendMessagesRequest: ({ messages }) => ({
body: {
messages,
metadata: {
chatId: chatIdRef.current,
projectId: selectedProjectRef.current,
model: selectedModelRef.current,
},
},
}),
}),
[chatApiBase],
)
// Derive suggestions from the user's actual memories.
// The parent-supplied `emptyStateSuggestions` always wins if provided.
const { suggestions: dynamicSuggestions, isLoading: isSuggestionsLoading } =
useDynamicSuggestions(selectedProject)
const [pendingThreadLoad, setPendingThreadLoad] = useState<{
id: string
messages: UIMessage[]
} | null>(null)
// Adjust chat height based on scroll position (desktop only, grid mode only)
useEffect(() => {
if (isMobile) return
if (viewMode === "graph") return
if (layout === "page") return
const handleWindowScroll = () => {
const scrollThreshold = 80
const scrollY = window.scrollY
const progress = Math.min(scrollY / scrollThreshold, 1)
const newOffset = 95 - progress * (95 - 15)
setHeightOffset(newOffset)
}
window.addEventListener("scroll", handleWindowScroll, { passive: true })
handleWindowScroll()
return () => window.removeEventListener("scroll", handleWindowScroll)
}, [isMobile, viewMode, layout])
const {
messages,
sendMessage,
status,
setMessages,
stop,
error,
clearError,
} = useChat({
id: currentChatId ?? undefined,
transport: chatTransport,
})
const chatStreamError = useMemo(
() => (error ? getNovaChatErrorCopy(error, selectedModel) : null),
[error, selectedModel],
)
const handleModelChange = useCallback(
(modelId: ModelId) => {
setSelectedModel(modelId)
clearError()
},
[clearError],
)
useEffect(() => {
if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
setMessages(pendingThreadLoad.messages)
setPendingThreadLoad(null)
}
}, [currentChatId, pendingThreadLoad, setMessages])
const checkIfScrolledToBottom = useCallback(() => {
if (!messagesContainerRef.current) return
const container = messagesContainerRef.current
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isAtBottom = distanceFromBottom <= 20
setIsScrolledToBottom(isAtBottom)
}, [])
const scrollToBottom = useCallback(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight
setIsScrolledToBottom(true)
}
}, [])
const handleSend = () => {
if (!input.trim() || status === "submitted" || status === "streaming")
return
if (!threadId) setThreadId(fallbackChatId)
analytics.chatMessageSent({ source: "typed" })
sendMessage({ text: input })
setInput("")
scrollToBottom()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleCopyMessage = useCallback((messageId: string, text: string) => {
analytics.chatMessageCopied({ message_id: messageId })
navigator.clipboard.writeText(text)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
}, [])
const handleLikeMessage = useCallback(
(messageId: string) => {
const wasLiked = messageFeedback[messageId] === "like"
setMessageFeedback((prev) => ({
...prev,
[messageId]: prev[messageId] === "like" ? null : "like",
}))
if (!wasLiked) {
analytics.chatMessageLiked({ message_id: messageId })
}
},
[messageFeedback],
)
const handleDislikeMessage = useCallback(
(messageId: string) => {
const wasDisliked = messageFeedback[messageId] === "dislike"
setMessageFeedback((prev) => ({
...prev,
[messageId]: prev[messageId] === "dislike" ? null : "dislike",
}))
if (!wasDisliked) {
analytics.chatMessageDisliked({ message_id: messageId })
}
},
[messageFeedback],
)
const handleToggleMemories = useCallback((messageId: string) => {
setExpandedMemories((prev) => {
const isExpanding = prev !== messageId
if (isExpanding) {
analytics.chatMemoryExpanded({ message_id: messageId })
} else {
analytics.chatMemoryCollapsed({ message_id: messageId })
}
return prev === messageId ? null : messageId
})
}, [])
const handleNewChat = useCallback(() => {
analytics.newChatCreated()
const newChatId = generateId()
chatIdRef.current = newChatId
setMessages([])
setThreadId(null)
setFallbackChatId(newChatId)
setInput("")
}, [setThreadId, setMessages])
const fetchThreads = useCallback(async () => {
setIsLoadingThreads(true)
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${selectedProject}`,
{ credentials: "include" },
)
if (response.ok) {
const data = await response.json()
setThreads(data.threads || [])
}
} catch (error) {
console.error("Failed to fetch threads:", error)
} finally {
setIsLoadingThreads(false)
}
}, [selectedProject])
const loadThread = useCallback(
async (id: string) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
{ credentials: "include" },
)
if (response.ok) {
const data = await response.json()
const uiMessages = data.messages.map(
(m: {
id: string
role: string
parts: Array<{ type: string }>
createdAt: string
}) => ({
id: m.id,
role: m.role,
// Strip tool parts — persisted format doesn't round-trip through
// convertToModelMessages correctly and causes tool_use/tool_result
// mismatch errors. Text history is sufficient for context.
parts: (m.parts || []).filter(
(p) => p.type === "text" || p.type === "reasoning",
),
createdAt: new Date(m.createdAt),
}),
)
setThreadId(id)
setPendingThreadLoad({ id, messages: uiMessages })
analytics.chatThreadLoaded({ thread_id: id })
setIsHistoryOpen(false)
setConfirmingDeleteId(null)
}
} catch (error) {
console.error("Failed to load thread:", error)
}
},
[setThreadId],
)
// Auto-restore thread from URL on mount (e.g. reload or direct link)
const didAutoLoadRef = useRef(false)
useEffect(() => {
if (didAutoLoadRef.current) return
if (!threadId) return
didAutoLoadRef.current = true
loadThread(threadId)
}, [threadId, loadThread])
const deleteThread = useCallback(
async (threadId: string) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
{ method: "DELETE", credentials: "include" },
)
if (response.ok) {
analytics.chatThreadDeleted({ thread_id: threadId })
setThreads((prev) => prev.filter((t) => t.id !== threadId))
if (currentChatId === threadId) {
handleNewChat()
}
}
} catch (error) {
console.error("Failed to delete thread:", error)
} finally {
setConfirmingDeleteId(null)
}
},
[currentChatId, handleNewChat],
)
const formatRelativeTime = (isoString: string): string => {
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | null
const isInEditableContext =
activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" ||
activeElement?.isContentEditable ||
activeElement?.closest('[contenteditable="true"]')
if (
e.key.toLowerCase() === "t" &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
isChatOpen &&
!isInEditableContext
) {
e.preventDefault()
handleNewChat()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isChatOpen, handleNewChat])
// Send queued message when chat opens
useEffect(() => {
if (
isChatOpen &&
queuedMessage &&
status !== "submitted" &&
status !== "streaming" &&
sentQueuedMessageRef.current !== queuedMessage
) {
if (initialSelectedModel && selectedModel !== initialSelectedModel) {
setSelectedModel(initialSelectedModel)
return
}
sentQueuedMessageRef.current = queuedMessage
if (!threadId) setThreadId(fallbackChatId)
analytics.chatMessageSent({ source: queuedMessageSource })
sendMessage({ text: queuedMessage })
onConsumeQueuedMessage?.()
}
}, [
isChatOpen,
queuedMessage,
queuedMessageSource,
initialSelectedModel,
selectedModel,
status,
sendMessage,
onConsumeQueuedMessage,
fallbackChatId,
setThreadId,
threadId,
])
// Reset the sent message ref when queued message is consumed
useEffect(() => {
if (!queuedMessage) {
sentQueuedMessageRef.current = null
}
}, [queuedMessage])
// Scroll to bottom when a new user message is added
useEffect(() => {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === "user" && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight
setIsScrolledToBottom(true)
}
// Always check scroll position when messages change
checkIfScrolledToBottom()
}, [messages, checkIfScrolledToBottom])
// Add scroll event listener to track scroll position
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
requestAnimationFrame(() => {
checkIfScrolledToBottom()
})
}
container.addEventListener("scroll", handleScroll, { passive: true })
// Initial check with a small delay to ensure DOM is ready
setTimeout(() => {
checkIfScrolledToBottom()
}, 100)
// Also observe resize to detect content height changes
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
checkIfScrolledToBottom()
})
})
resizeObserver.observe(container)
return () => {
container.removeEventListener("scroll", handleScroll)
resizeObserver.disconnect()
}
}, [checkIfScrolledToBottom])
if (!isChatOpen) {
return null
}
const isStackedInput = layout === "page"
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
const chatHistorySheet = (
<Sheet
open={isHistoryOpen}
onOpenChange={(open) => {
setIsHistoryOpen(open)
if (open) {
fetchThreads()
analytics.chatHistoryViewed?.()
} else {
setConfirmingDeleteId(null)
}
}}
>
<SheetContent
side="right"
className={cn(
"flex h-full max-h-dvh w-full flex-col gap-0 overflow-hidden border-[#17181AB2] bg-[#0A0E14] p-0 text-white sm:max-w-md",
"[&>button]:text-[#FAFAFA]",
dmSansClassName(),
)}
>
<SheetHeader className="shrink-0 space-y-1 border-[#17181AB2] border-b px-6 pt-6 pb-4">
<SheetTitle>Chat History</SheetTitle>
<SheetDescription className="text-[#737373]">
Space: {chatSpaceLabel}
</SheetDescription>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1 px-6">
<div className="py-4">
{isLoadingThreads ? (
<div className="flex items-center justify-center py-8">
<SuperLoader label="Loading…" />
</div>
) : threads.length === 0 ? (
<div className="py-8 text-center text-sm text-[#737373]">
No conversations yet
</div>
) : (
<div className="flex flex-col gap-1">
{threads.map((thread) => {
const isActive = thread.id === currentChatId
return (
<button
key={thread.id}
type="button"
onClick={() => loadThread(thread.id)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors",
isActive ? "bg-[#267BF1]/10" : "hover:bg-[#17181A]",
)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{thread.title || "Untitled Chat"}
</div>
<div className="text-xs text-[#737373]">
{formatRelativeTime(thread.updatedAt)}
</div>
</div>
{confirmingDeleteId === thread.id ? (
<div className="ml-2 flex items-center gap-1">
<Button
type="button"
size="icon"
onClick={(e) => {
e.stopPropagation()
deleteThread(thread.id)
}}
className="size-7 bg-red-500 text-white hover:bg-red-600"
>
<Check className="size-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(null)
}}
className="size-7"
>
<XIcon className="size-3 text-[#737373]" />
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(thread.id)
}}
className="ml-2 size-7"
>
<Trash2 className="size-3 text-[#737373]" />
</Button>
)}
</button>
)
})}
</div>
)}
</div>
</ScrollArea>
<div className="shrink-0 border-[#17181AB2] border-t p-4">
<Button
variant="outline"
className="w-full border-[#161F2C] border-dashed bg-transparent hover:bg-[#17181A]"
onClick={() => {
handleNewChat()
setIsHistoryOpen(false)
}}
>
<Plus className="mr-1 size-4" /> New Conversation
</Button>
</div>
</SheetContent>
</Sheet>
)
const chatToolbarActions = (
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setIsHistoryOpen(true)}
className={cn(
"flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-[#161F2C] bg-[#000000] transition-colors hover:bg-[#161F2C]",
dmSansClassName(),
)}
aria-label="Chat history"
>
<HistoryIcon className="size-4 text-[#FAFAFA]" />
</button>
<button
type="button"
onClick={handleNewChat}
title="New chat (T)"
className={cn(
"flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-[#161F2C] bg-[#000000] transition-colors hover:bg-[#161F2C]",
dmSansClassName(),
)}
aria-label="New chat"
>
<SquarePenIcon className="size-4 text-[#FAFAFA]" />
</button>
</div>
)
const pageDesktopToolbarRow = isPageDesktop ? (
<div
className={cn(
"flex w-full shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-1 z-10",
dmSansClassName(),
)}
>
{chatToolbarActions}
</div>
) : null
const shell = (
<>
{showHeaderRow ? (
<div
className={cn(
"flex items-center justify-between px-0 z-10",
isPageDesktop
? "relative shrink-0 pt-2 pb-1"
: "absolute top-0 right-0 left-0 pt-4 px-4",
!isMobile && !isPageDesktop && "rounded-t-2xl",
)}
>
<div className="mr-2 flex min-w-0 flex-1 items-center gap-2">
{layout === "page" && isMobile && (
<Button
type="button"
variant="headers"
className="h-10! w-10! shrink-0 cursor-pointer rounded-full border-[#73737333] bg-[#0D121A] p-0!"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsChatOpen(false)}
aria-label="Back to memories"
>
<ArrowLeft className="size-4 text-[#737373]" />
</Button>
)}
{!isStackedInput && (
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
<div
className={cn(
"inline-flex h-10 max-w-[min(192px,42vw)] shrink min-w-0 items-center rounded-full border border-[#73737333] bg-[#0D121A] px-3",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-white">
{chatSpaceLabel}
</span>
</div>
</>
)}
</div>
{chatToolbarActions}
</div>
) : null}
<div
ref={messagesContainerRef}
className={cn(
"relative flex-1 overflow-y-auto scrollbar-thin",
isPageDesktop && "min-h-0",
"px-4",
dmSansClassName(),
)}
>
{isInputExpanded && (
<div
className={cn(
"absolute inset-0 z-10! pointer-events-none",
isPageDesktop ? "rounded-none" : "rounded-2xl",
)}
style={{ backgroundColor: "#000000E5" }}
/>
)}
{messages.length === 0 && (
<ChatEmptyStatePlaceholder
onSuggestionClick={(suggestion) => {
analytics.chatSuggestedQuestionClicked()
analytics.chatMessageSent({ source: "suggested" })
sendMessage({ text: suggestion })
}}
suggestions={emptyStateSuggestions ?? dynamicSuggestions}
isLoadingSuggestions={
!emptyStateSuggestions && isSuggestionsLoading
}
/>
)}
<div
className={
messages.length > 0
? cn(
"flex flex-col space-y-3 min-h-full justify-end",
isPageDesktop ? "pt-2" : "pt-14",
)
: ""
}
>
{messages.map((message, index) => (
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
<div
key={message.id}
className={cn(
"flex gap-2 w-full",
message.role === "user" ? "justify-end" : "justify-start",
)}
onMouseEnter={() =>
message.role === "assistant" && setHoveredMessageId(message.id)
}
onMouseLeave={() =>
message.role === "assistant" && setHoveredMessageId(null)
}
>
{message.role === "user" ? (
<UserMessage
message={message}
copiedMessageId={copiedMessageId}
onCopy={handleCopyMessage}
/>
) : (
<AgentMessage
message={message}
index={index}
messagesLength={messages.length}
hoveredMessageId={hoveredMessageId}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
expandedMemories={expandedMemories}
onCopy={handleCopyMessage}
onLike={handleLikeMessage}
onDislike={handleDislikeMessage}
onToggleMemories={handleToggleMemories}
/>
)}
</div>
))}
{(status === "submitted" || status === "streaming") && (
<div className="flex gap-2">
<SuperLoader label="Thinking…" />
</div>
)}
</div>
</div>
{!isScrolledToBottom && messages.length > 0 && (
<div className="absolute bottom-24 left-0 right-0 flex justify-center z-50 pointer-events-none">
<button
type="button"
className="cursor-pointer pointer-events-auto"
onClick={scrollToBottom}
>
<div className="rounded-full p-2 bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset] hover:bg-[#0F1620] transition-colors">
<ChevronDownIcon className="size-4 text-white" />
</div>
</button>
</div>
)}
{chatStreamError && (
<div
role="alert"
className={cn(
"mb-2 rounded-lg bg-amber-950/40 px-3 py-2 text-sm text-amber-50/95",
isPageDesktop ? "mx-0" : "mx-4",
dmSansClassName(),
)}
>
<div className="flex justify-between gap-2 items-start">
<div className="min-w-0">
<p className="font-medium leading-snug">
{chatStreamError.title}
</p>
<p className="text-xs text-amber-100/70 mt-1 leading-snug">
{chatStreamError.body}
</p>
{chatStreamError.otherModels.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{chatStreamError.otherModels.map((id) => {
const m = modelNames[id]
return (
<Button
key={id}
type="button"
size="sm"
variant="secondary"
className="h-8 text-xs rounded-full bg-[#141922] border-[#73737333] hover:bg-[#1a2230] text-white/90"
onClick={() => {
handleModelChange(id)
analytics.modelChanged({ model: id })
}}
>
Switch to {m.name} {m.version}
</Button>
)
})}
</div>
)}
</div>
<button
type="button"
onClick={clearError}
className="shrink-0 p-1 rounded-md text-amber-200/50 hover:text-amber-100/90 hover:bg-white/5"
aria-label="Dismiss error"
>
<XIcon className="size-4" />
</button>
</div>
</div>
)}
<div className="shrink-0">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={handleSend}
onStop={stop}
onKeyDown={handleKeyDown}
isResponding={status === "submitted" || status === "streaming"}
activeStatus={
status === "submitted"
? "Thinking…"
: status === "streaming"
? "Structuring response…"
: "Waiting for input…"
}
onExpandedChange={setIsInputExpanded}
chainOfThoughtComponent={
messages.length > 0 ? <ChainOfThought messages={messages} /> : null
}
stackedToolbar={
isStackedInput ? (
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
minimal
/>
<div
className={cn(
"inline-flex max-w-[min(160px,35vw)] shrink min-w-0 items-center rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5",
dmSansClassName(),
)}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-[#FAFAFA]">
{chatSpaceLabel}
</span>
</div>
</>
) : undefined
}
/>
</div>
</>
)
return (
<motion.div
key="open"
className={cn(
"relative flex flex-col backdrop-blur-md",
isMobile
? "fixed inset-0 z-50 m-0 h-dvh w-full rounded-none"
: isPageDesktop
? "flex h-full min-h-0 w-full min-w-0 flex-1 flex-col basis-0 rounded-none border-x-0"
: "m-4 mt-2 w-[450px] rounded-2xl",
dmSansClassName(),
)}
style={
isMobile
? undefined
: isPageDesktop
? undefined
: {
height: `calc(100vh - ${heightOffset}px)`,
}
}
initial={
isMobile
? { y: "100%", opacity: 0 }
: layout === "page"
? { opacity: 0, y: 20 }
: { x: "100px", opacity: 0 }
}
animate={{ x: 0, y: 0, opacity: 1 }}
exit={
isMobile
? { y: "100%", opacity: 0 }
: layout === "page"
? { opacity: 0, y: 12 }
: { x: "100px", opacity: 0 }
}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>
{chatHistorySheet}
{isPageDesktop ? (
<div className="flex h-full min-h-0 w-full flex-1 flex-row">
<ChatGraphContextRail messages={messages} />
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[720px] shrink-0 basis-[min(720px,50vw)] flex-col">
{pageDesktopToolbarRow}
<div className="relative mx-auto flex h-full min-h-0 w-full min-w-0 max-w-[720px] flex-1 flex-col">
{shell}
</div>
</div>
</div>
) : (
shell
)}
</motion.div>
)
}
export { HomeChatComposer } from "./home-chat-composer"