diff --git a/apps/web/app/(app)/onboarding/setup/page.tsx b/apps/web/app/(app)/onboarding/setup/page.tsx index 35035233..b279bf2a 100644 --- a/apps/web/app/(app)/onboarding/setup/page.tsx +++ b/apps/web/app/(app)/onboarding/setup/page.tsx @@ -47,7 +47,7 @@ export default function SetupPage() { } return ( -
+
diff --git a/apps/web/app/(app)/onboarding/welcome/page.tsx b/apps/web/app/(app)/onboarding/welcome/page.tsx index e0df2445..d6181f07 100644 --- a/apps/web/app/(app)/onboarding/welcome/page.tsx +++ b/apps/web/app/(app)/onboarding/welcome/page.tsx @@ -207,7 +207,7 @@ export default function WelcomePage() { const showUserSupermemory = currentStep === "username" return ( -
+
type DocumentWithMemories = DocumentsResponse["documents"][0] +/* + * Animated gradient — commented for now (restore with import + JSX in NewPage). +function subscribeViewportWidth(cb: () => void) { + window.addEventListener("resize", cb) + return () => window.removeEventListener("resize", cb) +} + +function getViewportWidth() { + return window.innerWidth +} + +const GRADIENT_TOP_WIDTH_MAX = 1440 + +function gradientTopPositionForWidth(width: number) { + const minW = 320 + const pctWide = 15 + const pctNarrow = 70 + const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) + const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) + const eased = t * t + return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%` +} +*/ + function ViewErrorFallback() { return (
@@ -122,7 +146,6 @@ export default function NewPage() { "fullscreen", fullscreenParam, ) - const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam) const [integrationFromUrl, setIntegration] = useQueryState( "integration", integrationParam, @@ -138,6 +161,10 @@ export default function NewPage() { // Ephemeral local state (not worth URL-encoding) const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) + const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedMessageSource, setQueuedMessageSource] = useState< + "highlight" | "home" + >("highlight") const [selectedDocument, setSelectedDocument] = useState(null) @@ -375,11 +402,29 @@ export default function NewPage() { const handleHighlightsChat = useCallback( (seed: string) => { setQueuedChatSeed(seed) - setIsChatOpen(true) + setQueuedChatModel(null) + setQueuedMessageSource("highlight") + void setViewMode("chat") }, - [setIsChatOpen], + [setViewMode], ) + const handleHomeChatStart = useCallback( + (message: string, model: ModelId) => { + setQueuedChatSeed(message) + setQueuedChatModel(model) + setQueuedMessageSource("home") + void setViewMode("chat") + }, + [setViewMode], + ) + + const consumeQueuedChat = useCallback(() => { + setQueuedChatSeed(null) + setQueuedChatModel(null) + setQueuedMessageSource("highlight") + }, []) + const handleHighlightsShowRelated = useCallback( (query: string) => { analytics.searchOpened({ source: "highlight_related" }) @@ -409,25 +454,30 @@ export default function NewPage() { [setAddDoc], ) - const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile + const isChatView = viewMode === "chat" const isGraphMode = viewMode === "graph" && !isMobile + // Animated gradient — commented for now (use with useSyncExternalStore + helpers above). + // const viewportWidth = useSyncExternalStore( + // subscribeViewportWidth, + // getViewportWidth, + // () => GRADIENT_TOP_WIDTH_MAX, + // ) + // const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) + return (
- + {/* AnimatedGradientBackground: off for now (import + helpers commented above). */} {isGraphMode && (
)}
setIsChatOpen(true)} onOpenSearch={() => { analytics.searchOpened({ source: "header" }) setIsSearchOpen(true) }} />
-
+
}> - {viewMode === "integrations" ? ( -
+ {isChatView ? ( +
+ { + if (!open) void setViewMode("list") + }} + queuedMessage={queuedChatSeed} + onConsumeQueuedMessage={consumeQueuedChat} + queuedMessageSource={queuedMessageSource} + initialSelectedModel={queuedChatModel} + emptyStateSuggestions={highlightsData?.questions} + /> +
+ ) : viewMode === "integrations" ? ( +
) : viewMode === "graph" && !isMobile ? ( -
- +
+
) : ( -
+
)} -
- - - setIsChatOpen(open)} - queuedMessage={queuedChatSeed} - onConsumeQueuedMessage={() => setQueuedChatSeed(null)} - emptyStateSuggestions={highlightsData?.questions} - /> - - -
- {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 ( +
+
+ setInput(e.target.value)} + onSend={send} + onStop={() => {}} + onKeyDown={handleKeyDown} + isResponding={false} + activeStatus="Waiting for input..." + stackedToolbar={ + <> + +
+ + {chatSpaceLabel} + +
+ + } + /> +
+
+ ) +} 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 ( + + + + 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 - - - - ) : ( - -
-
- -
- - {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 ? ( +
+
-
-
- { - setIsHistoryOpen(open) - if (open) { - fetchThreads() - analytics.chatHistoryViewed?.() - } else { - setConfirmingDeleteId(null) - } - }} - > - - - - - - Chat History - - Space: {chatSpaceLabel} - - - - {isLoadingThreads ? ( -
- + ) : threads.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {threads.map((thread) => { + const isActive = thread.id === currentChatId + return ( + - -
- ) : ( - - )} - - ) - })} -
- )} -
- -
-
- - {/* - {isMobile ? ( - - ) : ( - - )} - */} -
-
-
- {isInputExpanded && ( -
- )} - {messages.length === 0 && ( - { - analytics.chatSuggestedQuestionClicked() - analytics.chatMessageSent({ source: "suggested" }) - sendMessage({ text: suggestion }) - }} - suggestions={emptyStateSuggestions} - /> - )} -
0 - ? "flex flex-col space-y-3 min-h-full justify-end pt-14" - : "", - )} - > - {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") && ( -
- -
- )} -
-
- - {!isScrolledToBottom && messages.length > 0 && ( -
- -
- )} - - {chatStreamError && ( -
-
-
-

- {chatStreamError.title} -

-

- {chatStreamError.body} -

- {chatStreamError.otherModels.length > 0 && ( -
- {chatStreamError.otherModels.map((id) => { - const m = modelNames[id] - return ( + {confirmingDeleteId === thread.id ? ( +
- ) - })} -
- )} -
- + +
+ ) : ( + + )} + + ) + })}
+ )} +
+ +
+ +
+ + + ) + + const chatToolbarActions = ( +
+ + +
+ ) + + const pageDesktopToolbarRow = isPageDesktop ? ( +
+ {chatToolbarActions} +
+ ) : null + + const shell = ( + <> + {showHeaderRow ? ( +
+
+ {layout === "page" && isMobile && ( + + )} + {!isStackedInput && ( + <> + +
+ + {chatSpaceLabel} + +
+ + )} +
+ {chatToolbarActions} +
+ ) : null} +
+ {isInputExpanded && ( +
+ )} + {messages.length === 0 && ( + { + analytics.chatSuggestedQuestionClicked() + analytics.chatMessageSent({ source: "suggested" }) + sendMessage({ text: suggestion }) + }} + suggestions={emptyStateSuggestions} + /> + )} +
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 +
+ message.role === "assistant" && setHoveredMessageId(message.id) + } + onMouseLeave={() => + message.role === "assistant" && setHoveredMessageId(null) + } + > + {message.role === "user" ? ( + + ) : ( + + )} +
+ ))} + {(status === "submitted" || status === "streaming") && ( +
+
)} +
+
- 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 ? ( - - ) : null - } - /> - + {!isScrolledToBottom && messages.length > 0 && ( +
+ +
)} - + + {chatStreamError && ( +
+
+
+

+ {chatStreamError.title} +

+

+ {chatStreamError.body} +

+ {chatStreamError.otherModels.length > 0 && ( +
+ {chatStreamError.otherModels.map((id) => { + const m = modelNames[id] + return ( + + ) + })} +
+ )} +
+ +
+
+ )} + +
+ 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 ? : null + } + stackedToolbar={ + isStackedInput ? ( + <> + +
+ + {chatSpaceLabel} + +
+ + ) : undefined + } + /> +
+ + ) + + return ( + + {chatHistorySheet} + {isPageDesktop ? ( + <> + {pageDesktopToolbarRow} +
+ {shell} +
+ + ) : ( + shell + )} +
) } + +export { HomeChatComposer } from "./home-chat-composer" diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index 40c1949d..80191019 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react" import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { useRef, useState } from "react" +import { type ReactNode, useRef, useState } from "react" import { motion } from "motion/react" import { SendButton, StopButton } from "./actions" @@ -18,6 +18,8 @@ interface ChatInputProps { activeStatus?: string chainOfThoughtComponent?: React.ReactNode onExpandedChange?: (expanded: boolean) => void + /** Model + space controls on one row with send; textarea full-width above */ + stackedToolbar?: ReactNode } export default function ChatInput({ @@ -30,6 +32,7 @@ export default function ChatInput({ activeStatus, chainOfThoughtComponent, onExpandedChange, + stackedToolbar, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) @@ -103,31 +106,59 @@ export default function ChatInput({ /> )} -
-