+
}>
- {viewMode === "integrations" ? (
-
+ {isChatView ? (
+
) : viewMode === "graph" && !isMobile ? (
-
+ {
+ if (!open) void setViewMode("list")
+ }}
+ queuedMessage={queuedChatSeed}
+ onConsumeQueuedMessage={consumeQueuedChat}
+ queuedMessageSource={queuedMessageSource}
+ initialSelectedModel={queuedChatModel}
+ emptyStateSuggestions={highlightsData?.questions}
+ />
+
+ ) : viewMode === "integrations" ? (
+
-
+
+
) : (
-
+
)}
-
- {isMobile && (
- setIsChatOpen(open)}
- queuedMessage={queuedChatSeed}
- onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
- emptyStateSuggestions={highlightsData?.questions}
- />
+ {viewMode === "list" && (
+
+
void
+ className?: string
+}) {
+ const [input, setInput] = useState("")
+ const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro")
+ const { selectedProject } = useProject()
+ const { allProjects } = useContainerTags()
+ const chatSpaceLabel = useMemo(
+ () =>
+ getChatSpaceDisplayLabel({
+ selectedProject,
+ allProjects,
+ }),
+ [selectedProject, allProjects],
+ )
+
+ const send = useCallback(() => {
+ const t = input.trim()
+ if (!t) return
+ onStartChat(t, selectedModel)
+ setInput("")
+ }, [input, onStartChat, selectedModel])
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ send()
+ }
+ }
+
+ return (
+
+
+
+ Chat with Nova
+
+
+ )
+}
+
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("gemini-2.5-pro")
+ const [selectedModel, setSelectedModel] = useState(
+ () => initialSelectedModel ?? "gemini-2.5-pro",
+ )
const [copiedMessageId, setCopiedMessageId] = useState(null)
const [hoveredMessageId, setHoveredMessageId] = useState(null)
const [messageFeedback, setMessageFeedback] = useState<
@@ -182,6 +233,7 @@ export function ChatSidebar({
useEffect(() => {
if (isMobile) return
if (viewMode === "graph") return
+ if (layout === "page") return
const handleWindowScroll = () => {
const scrollThreshold = 80
@@ -195,7 +247,7 @@ export function ChatSidebar({
handleWindowScroll()
return () => window.removeEventListener("scroll", handleWindowScroll)
- }, [isMobile, viewMode])
+ }, [isMobile, viewMode, layout])
const {
messages,
@@ -263,10 +315,6 @@ export function ChatSidebar({
}
}
- const toggleChat = () => {
- setIsChatOpen(!isChatOpen)
- }
-
const handleCopyMessage = useCallback((messageId: string, text: string) => {
analytics.chatMessageCopied({ message_id: messageId })
navigator.clipboard.writeText(text)
@@ -440,11 +488,18 @@ export function ChatSidebar({
sentQueuedMessageRef.current !== queuedMessage
) {
sentQueuedMessageRef.current = queuedMessage
- analytics.chatMessageSent({ source: "highlight" })
+ analytics.chatMessageSent({ source: queuedMessageSource })
sendMessage({ text: queuedMessage })
onConsumeQueuedMessage?.()
}
- }, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage])
+ }, [
+ isChatOpen,
+ queuedMessage,
+ queuedMessageSource,
+ status,
+ sendMessage,
+ onConsumeQueuedMessage,
+ ])
// Reset the sent message ref when queued message is consumed
useEffect(() => {
@@ -496,431 +551,474 @@ export function ChatSidebar({
}
}, [checkIfScrolledToBottom])
- return (
-
- {!isChatOpen ? (
-
-
-
-
- Chat with Nova
-
-
-
- ) : (
-
-
-
-
- setIsChatOpen(open)}
- queuedMessage={queuedChatSeed}
- onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
- emptyStateSuggestions={highlightsData?.questions}
- />
-
-
-
+
)}
+
+
+
+
+ )
+}
diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx
index 106d2f7d..ad269ea9 100644
--- a/apps/web/components/chat/index.tsx
+++ b/apps/web/components/chat/index.tsx
@@ -3,21 +3,21 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/react"
-import { motion, AnimatePresence } from "motion/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 {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@ui/components/dialog"
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@ui/components/sheet"
import { ScrollArea } from "@ui/components/scroll-area"
import {
+ ArrowLeft,
Check,
ChevronDownIcon,
HistoryIcon,
@@ -99,22 +99,73 @@ function ChatEmptyStatePlaceholder({
)
}
+export function ChatLaunchFab({
+ onOpen,
+ isMobile,
+}: {
+ onOpen: () => void
+ isMobile: boolean
+}) {
+ return (
+
+ setInput(e.target.value)}
+ onSend={send}
+ onStop={() => {}}
+ onKeyDown={handleKeyDown}
+ isResponding={false}
+ activeStatus="Waiting for input..."
+ stackedToolbar={
+ <>
+
+
+
+
+ {chatSpaceLabel}
+
+
+ >
+ }
+ />
+
-
-
-
-
- {chatSpaceLabel}
-
+ if (!isChatOpen) {
+ return null
+ }
+
+ const isStackedInput = layout === "page"
+ const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
+
+ const chatHistorySheet = (
+ {
+ setIsHistoryOpen(open)
+ if (open) {
+ fetchThreads()
+ analytics.chatHistoryViewed?.()
+ } else {
+ setConfirmingDeleteId(null)
+ }
+ }}
+ >
+ button]:text-[#FAFAFA]",
+ dmSansClassName(),
+ )}
+ >
+
+ Chat History
+
+ Space: {chatSpaceLabel}
+
+
+
+
+ {isLoadingThreads ? (
+
-
+
-
-