diff --git a/app/globals.css b/app/globals.css index 3409b98..68cfb13 100644 --- a/app/globals.css +++ b/app/globals.css @@ -20,6 +20,18 @@ .text-balance { text-wrap: balance; } + + .-webkit-overflow-scrolling-touch { + -webkit-overflow-scrolling: touch; + } + + .touch-pan-y { + touch-action: pan-y; + } + + .overscroll-behavior-contain { + overscroll-behavior: contain; + } } @layer base { @@ -101,6 +113,12 @@ body { @apply bg-background text-foreground; + overflow-x: hidden; + position: relative; + } + + html { + overflow-x: hidden; } } @@ -162,3 +180,33 @@ .suggestion-highlight { @apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40; } + +/* minimal scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 3px; + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); +} + +::-webkit-scrollbar-corner { + background: transparent; +} + +/* firefox scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) transparent; +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 5b08bb5..e6d2213 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -43,7 +43,7 @@ export function AppSidebar({ user }: { user: User | undefined }) { - New Chat + New Chat diff --git a/components/chat-header.tsx b/components/chat-header.tsx index 8370906..a8775c5 100644 --- a/components/chat-header.tsx +++ b/components/chat-header.tsx @@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button'; import { PlusIcon, VercelIcon } from './icons'; import { useSidebar } from './ui/sidebar'; import { memo } from 'react'; -import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { type VisibilityType, VisibilitySelector } from './visibility-selector'; import type { Session } from 'next-auth'; @@ -34,22 +33,17 @@ function PureChatHeader({ {(!open || windowWidth < 768) && ( - - - - - New Chat - + )} {!isReadonly && ( diff --git a/components/chat.tsx b/components/chat.tsx index 959373b..f62a703 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import { ChatHeader } from '@/components/chat-header'; import type { Vote } from '@/lib/db/schema'; -import { fetcher, fetchWithErrorHandlers, generateUUID, cn } from '@/lib/utils'; +import { fetcher, fetchWithErrorHandlers, generateUUID } from '@/lib/utils'; import { Artifact } from './artifact'; import { MultimodalInput } from './multimodal-input'; import { Messages } from './messages'; @@ -128,7 +128,7 @@ export function Chat({ return ( <> -
+
-
+
{!isReadonly && ( ; export const Actions = ({ className, children, ...props }: ActionsProps) => ( -
+
{children}
); diff --git a/components/elements/conversation.tsx b/components/elements/conversation.tsx index c817ad7..97d3f31 100644 --- a/components/elements/conversation.tsx +++ b/components/elements/conversation.tsx @@ -11,7 +11,10 @@ export type ConversationProps = ComponentProps; export const Conversation = ({ className, ...props }: ConversationProps) => ( & { duration?: number; }; -const AUTO_CLOSE_DELAY = 1000; +const AUTO_CLOSE_DELAY = 500; const MS_IN_S = 1000; export const Reasoning = memo( @@ -98,7 +98,7 @@ export const Reasoning = memo( value={{ isStreaming, isOpen, setIsOpen, duration }} > Thinking...

) : ( -

Thought for {duration} seconds

+

Thought for {duration}s

)} @@ -155,8 +155,8 @@ export const ReasoningContent = memo( ({ className, children, ...props }: ReasoningContentProps) => ( { return (
Hello there! @@ -20,7 +20,7 @@ export const Greeting = () => { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} transition={{ delay: 0.6 }} - className="text-2xl text-zinc-500" + className="text-xl md:text-2xl text-zinc-500" > How can I help you today? diff --git a/components/message-actions.tsx b/components/message-actions.tsx index 8e85548..31373d0 100644 --- a/components/message-actions.tsx +++ b/components/message-actions.tsx @@ -3,7 +3,7 @@ import { useCopyToClipboard } from 'usehooks-ts'; import type { Vote } from '@/lib/db/schema'; -import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons'; +import { CopyIcon, ThumbDownIcon, ThumbUpIcon, PencilEditIcon } from './icons'; import { Actions, Action } from './elements/actions'; import { memo } from 'react'; import equal from 'fast-deep-equal'; @@ -15,134 +15,156 @@ export function PureMessageActions({ message, vote, isLoading, + setMode, }: { chatId: string; message: ChatMessage; vote: Vote | undefined; isLoading: boolean; + setMode?: (mode: 'view' | 'edit') => void; }) { const { mutate } = useSWRConfig(); const [_, copyToClipboard] = useCopyToClipboard(); if (isLoading) return null; - if (message.role === 'user') return null; + + const textFromParts = message.parts + ?.filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n') + .trim(); + + const handleCopy = async () => { + if (!textFromParts) { + toast.error("There's no text to copy!"); + return; + } + + await copyToClipboard(textFromParts); + toast.success('Copied to clipboard!'); + }; + + // User messages get edit (on hover) and copy actions + if (message.role === 'user') { + return ( + +
+ {setMode && ( + setMode('edit')} + className="absolute top-0 -left-10 opacity-0 transition-opacity group-hover/message:opacity-100" + > + + + )} + + + +
+
+ ); + } return ( - - { - const textFromParts = message.parts - ?.filter((part) => part.type === 'text') - .map((part) => part.text) - .join('\n') - .trim(); + + + + - if (!textFromParts) { - toast.error("There's no text to copy!"); - return; - } + { + const upvote = fetch('/api/vote', { + method: 'PATCH', + body: JSON.stringify({ + chatId, + messageId: message.id, + type: 'up', + }), + }); - await copyToClipboard(textFromParts); - toast.success('Copied to clipboard!'); - }} - > - - + toast.promise(upvote, { + loading: 'Upvoting Response...', + success: () => { + mutate>( + `/api/vote?chatId=${chatId}`, + (currentVotes) => { + if (!currentVotes) return []; - { - const upvote = fetch('/api/vote', { - method: 'PATCH', - body: JSON.stringify({ - chatId, - messageId: message.id, - type: 'up', - }), - }); + const votesWithoutCurrent = currentVotes.filter( + (vote) => vote.messageId !== message.id, + ); - toast.promise(upvote, { - loading: 'Upvoting Response...', - success: () => { - mutate>( - `/api/vote?chatId=${chatId}`, - (currentVotes) => { - if (!currentVotes) return []; + return [ + ...votesWithoutCurrent, + { + chatId, + messageId: message.id, + isUpvoted: true, + }, + ]; + }, + { revalidate: false }, + ); - const votesWithoutCurrent = currentVotes.filter( - (vote) => vote.messageId !== message.id, - ); + return 'Upvoted Response!'; + }, + error: 'Failed to upvote response.', + }); + }} + > + + - return [ - ...votesWithoutCurrent, - { - chatId, - messageId: message.id, - isUpvoted: true, - }, - ]; - }, - { revalidate: false }, - ); + { + const downvote = fetch('/api/vote', { + method: 'PATCH', + body: JSON.stringify({ + chatId, + messageId: message.id, + type: 'down', + }), + }); - return 'Upvoted Response!'; - }, - error: 'Failed to upvote response.', - }); - }} - > - - + toast.promise(downvote, { + loading: 'Downvoting Response...', + success: () => { + mutate>( + `/api/vote?chatId=${chatId}`, + (currentVotes) => { + if (!currentVotes) return []; - { - const downvote = fetch('/api/vote', { - method: 'PATCH', - body: JSON.stringify({ - chatId, - messageId: message.id, - type: 'down', - }), - }); + const votesWithoutCurrent = currentVotes.filter( + (vote) => vote.messageId !== message.id, + ); - toast.promise(downvote, { - loading: 'Downvoting Response...', - success: () => { - mutate>( - `/api/vote?chatId=${chatId}`, - (currentVotes) => { - if (!currentVotes) return []; + return [ + ...votesWithoutCurrent, + { + chatId, + messageId: message.id, + isUpvoted: false, + }, + ]; + }, + { revalidate: false }, + ); - const votesWithoutCurrent = currentVotes.filter( - (vote) => vote.messageId !== message.id, - ); - - return [ - ...votesWithoutCurrent, - { - chatId, - messageId: message.id, - isUpvoted: false, - }, - ]; - }, - { revalidate: false }, - ); - - return 'Downvoted Response!'; - }, - error: 'Failed to downvote response.', - }); - }} - > - - + return 'Downvoted Response!'; + }, + error: 'Failed to downvote response.', + }); + }} + > + + ); } diff --git a/components/message.tsx b/components/message.tsx index 311a055..19dcb49 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -1,10 +1,9 @@ 'use client'; -import cx from 'classnames'; -import { AnimatePresence, motion } from 'framer-motion'; +import { motion } from 'framer-motion'; import { memo, useState } from 'react'; import type { Vote } from '@/lib/db/schema'; import { DocumentToolResult } from './document'; -import { PencilEditIcon, SparklesIcon, LoaderIcon } from './icons'; +import { SparklesIcon } from './icons'; import { Response } from './elements/response'; import { MessageContent } from './elements/message'; import { @@ -19,8 +18,6 @@ import { PreviewAttachment } from './preview-attachment'; import { Weather } from './weather'; import equal from 'fast-deep-equal'; import { cn, sanitizeText } from '@/lib/utils'; -import { Button } from './ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { MessageEditor } from './message-editor'; import { DocumentPreview } from './document-preview'; import { MessageReasoning } from './message-reasoning'; @@ -28,9 +25,6 @@ import type { UseChatHelpers } from '@ai-sdk/react'; import type { ChatMessage } from '@/lib/types'; import { useDataStream } from './data-stream-provider'; -// Type narrowing is handled by TypeScript's control flow analysis -// The AI SDK provides proper discriminated unions for tool calls - const PurePreviewMessage = ({ chatId, message, @@ -61,240 +55,234 @@ const PurePreviewMessage = ({ useDataStream(); return ( - - +
+ {message.role === 'assistant' && ( +
+ +
+ )} +
p.type === 'text' && p.text?.trim(), + ), + 'min-h-96': message.role === 'assistant' && requiresScrollPadding, + 'w-full': + (message.role === 'assistant' && + message.parts?.some( + (p) => p.type === 'text' && p.text?.trim(), + )) || + mode === 'edit', + 'max-w-[90%] sm:max-w-[min(fit-content,80%)]': message.role === 'user' && mode !== 'edit', - 'justify-start -ml-3': message.role === 'assistant', })} > - {message.role === 'assistant' && ( -
- + {attachmentsFromMessage.length > 0 && ( +
+ {attachmentsFromMessage.map((attachment) => ( + + ))}
)} -
- {attachmentsFromMessage.length > 0 && ( -
- {attachmentsFromMessage.map((attachment) => ( - - ))} -
- )} + {message.parts?.map((part, index) => { + const { type } = part; + const key = `message-${message.id}-part-${index}`; - {message.parts?.map((part, index) => { - const { type } = part; - const key = `message-${message.id}-part-${index}`; + if (type === 'reasoning' && part.text?.trim().length > 0) { + return ( + + ); + } - if (type === 'reasoning' && part.text?.trim().length > 0) { + if (type === 'text') { + if (mode === 'view') { return ( - - ); - } - - if (type === 'text') { - if (mode === 'view') { - return ( -
- {message.role === 'user' && !isReadonly && ( - - - - - Edit message - - )} - - - {sanitizeText(part.text)} - -
- ); - } - - if (mode === 'edit') { - return ( -
+ -
-
- -
-
- ); - } - } - - if (type === 'tool-getWeather') { - const { toolCallId, state } = part; - - return ( - - - - {state === 'input-available' && ( - - )} - {state === 'output-available' && ( - } - errorText={undefined} - /> - )} - - - ); - } - - if (type === 'tool-createDocument') { - const { toolCallId } = part; - - if (part.output && 'error' in part.output) { - return ( -
- Error creating document: {String(part.output.error)} -
- ); - } - - return ( - - ); - } - - if (type === 'tool-updateDocument') { - const { toolCallId } = part; - - if (part.output && 'error' in part.output) { - return ( -
- Error updating document: {String(part.output.error)} -
- ); - } - - return ( -
- + {sanitizeText(part.text)} +
); } - if (type === 'tool-requestSuggestions') { - const { toolCallId, state } = part; - + if (mode === 'edit') { return ( - - - - {state === 'input-available' && ( - - )} - {state === 'output-available' && ( - - Error: {String(part.output.error)} -
- ) : ( - - ) - } - errorText={undefined} - /> - )} - - +
+
+
+ +
+
); } - })} + } - {!isReadonly && ( - - )} -
+ if (type === 'tool-getWeather') { + const { toolCallId, state } = part; + + return ( + + + + {state === 'input-available' && ( + + )} + {state === 'output-available' && ( + } + errorText={undefined} + /> + )} + + + ); + } + + if (type === 'tool-createDocument') { + const { toolCallId } = part; + + if (part.output && 'error' in part.output) { + return ( +
+ Error creating document: {String(part.output.error)} +
+ ); + } + + return ( + + ); + } + + if (type === 'tool-updateDocument') { + const { toolCallId } = part; + + if (part.output && 'error' in part.output) { + return ( +
+ Error updating document: {String(part.output.error)} +
+ ); + } + + return ( +
+ +
+ ); + } + + if (type === 'tool-requestSuggestions') { + const { toolCallId, state } = part; + + return ( + + + + {state === 'input-available' && ( + + )} + {state === 'output-available' && ( + + Error: {String(part.output.error)} +
+ ) : ( + + ) + } + errorText={undefined} + /> + )} + + + ); + } + })} + + {!isReadonly && ( + + )}
- - +
+ ); }; @@ -319,21 +307,44 @@ export const ThinkingMessage = () => { -
-
+
+
-
- -
Hmm...
-
+
+
+ Thinking... +
); }; + +const LoadingText = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/components/messages.tsx b/components/messages.tsx index c3239dc..b191845 100644 --- a/components/messages.tsx +++ b/components/messages.tsx @@ -1,15 +1,14 @@ import { PreviewMessage, ThinkingMessage } from './message'; import { Greeting } from './greeting'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import type { Vote } from '@/lib/db/schema'; import equal from 'fast-deep-equal'; import type { UseChatHelpers } from '@ai-sdk/react'; -import { motion } from 'framer-motion'; import { useMessages } from '@/hooks/use-messages'; import type { ChatMessage } from '@/lib/types'; import { useDataStream } from './data-stream-provider'; -import { Conversation, ConversationContent, ConversationScrollButton } from './elements/conversation'; -import { cn } from '@/lib/utils'; +import { Conversation, ConversationContent } from './elements/conversation'; +import { ArrowDownIcon } from 'lucide-react'; interface MessagesProps { chatId: string; @@ -20,6 +19,7 @@ interface MessagesProps { regenerate: UseChatHelpers['regenerate']; isReadonly: boolean; isArtifactVisible: boolean; + selectedModelId: string; } function PureMessages({ @@ -31,12 +31,13 @@ function PureMessages({ regenerate, isReadonly, isArtifactVisible, + selectedModelId, }: MessagesProps) { const { containerRef: messagesContainerRef, endRef: messagesEndRef, - onViewportEnter, - onViewportLeave, + isAtBottom, + scrollToBottom, hasSentMessage, } = useMessages({ chatId, @@ -45,10 +46,28 @@ function PureMessages({ useDataStream(); + useEffect(() => { + if (status === 'submitted') { + requestAnimationFrame(() => { + const container = messagesContainerRef.current; + if (container) { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth' + }); + } + }); + } + }, [status]); + return ( -
- - +
+ + {messages.length === 0 && } {messages.map((message, index) => ( @@ -56,7 +75,9 @@ function PureMessages({ key={message.id} chatId={chatId} message={message} - isLoading={status === 'streaming' && messages.length - 1 === index} + isLoading={ + status === 'streaming' && messages.length - 1 === index + } vote={ votes ? votes.find((vote) => vote.messageId === message.id) @@ -74,17 +95,28 @@ function PureMessages({ {status === 'submitted' && messages.length > 0 && - messages[messages.length - 1].role === 'user' && } + messages[messages.length - 1].role === 'user' && + selectedModelId !== 'chat-model-reasoning' && ( + + )} - - + + {!isAtBottom && ( + + )}
); } @@ -93,6 +125,7 @@ export const Messages = memo(PureMessages, (prevProps, nextProps) => { if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true; if (prevProps.status !== nextProps.status) return false; + if (prevProps.selectedModelId !== nextProps.selectedModelId) return false; if (prevProps.messages.length !== nextProps.messages.length) return false; if (!equal(prevProps.messages, nextProps.messages)) return false; if (!equal(prevProps.votes, nextProps.votes)) return false; diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 04a66dd..c601ff1 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -80,13 +80,13 @@ function PureMultimodalInput({ const adjustHeight = () => { if (textareaRef.current) { - textareaRef.current.style.height = '72px'; + textareaRef.current.style.height = '44px'; } }; const resetHeight = () => { if (textareaRef.current) { - textareaRef.current.style.height = '72px'; + textareaRef.current.style.height = '44px'; } }; @@ -226,7 +226,7 @@ function PureMultimodalInput({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} transition={{ type: 'spring', stiffness: 300, damping: 20 }} - className="absolute bottom-28 left-1/2 z-50 -translate-x-1/2" + className="absolute -top-12 left-1/2 z-50 -translate-x-1/2" > @@ -440,47 +441,18 @@ function PureStopButton({ return ( ); } const StopButton = memo(PureStopButton); - -function PureSendButton({ - submitForm, - input, - uploadQueue, -}: { - submitForm: () => void; - input: string; - uploadQueue: Array; -}) { - return ( - - ); -} - -const SendButton = memo(PureSendButton, (prevProps, nextProps) => { - if (prevProps.uploadQueue.length !== nextProps.uploadQueue.length) - return false; - if (prevProps.input !== nextProps.input) return false; - return true; -}); diff --git a/components/sidebar-toggle.tsx b/components/sidebar-toggle.tsx index 83f4767..c21db3d 100644 --- a/components/sidebar-toggle.tsx +++ b/components/sidebar-toggle.tsx @@ -22,12 +22,12 @@ export function SidebarToggle({ data-testid="sidebar-toggle-button" onClick={toggleSidebar} variant="outline" - className="md:px-2 md:h-fit" + className="px-1 h-8 md:px-2 md:h-fit" > - Toggle Sidebar + Toggle Sidebar ); } diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index dd0d59c..80c4176 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; -import { Sheet, SheetContent } from '@/components/ui/sheet'; +import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'; import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, @@ -214,6 +214,7 @@ const Sidebar = React.forwardRef< } side={side} > + Navigation Menu
{children}
diff --git a/hooks/use-messages.tsx b/hooks/use-messages.tsx index 7723584..ac13fd9 100644 --- a/hooks/use-messages.tsx +++ b/hooks/use-messages.tsx @@ -21,13 +21,6 @@ export function useMessages({ const [hasSentMessage, setHasSentMessage] = useState(false); - useEffect(() => { - if (chatId) { - scrollToBottom('instant'); - setHasSentMessage(false); - } - }, [chatId, scrollToBottom]); - useEffect(() => { if (status === 'submitted') { setHasSentMessage(true); diff --git a/hooks/use-scroll-to-bottom.tsx b/hooks/use-scroll-to-bottom.tsx index 29e1506..a363b54 100644 --- a/hooks/use-scroll-to-bottom.tsx +++ b/hooks/use-scroll-to-bottom.tsx @@ -1,27 +1,47 @@ import useSWR from 'swr'; -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; type ScrollFlag = ScrollBehavior | false; export function useScrollToBottom() { const containerRef = useRef(null); const endRef = useRef(null); - - const { data: isAtBottom = false, mutate: setIsAtBottom } = useSWR( - 'messages:is-at-bottom', - null, - { fallbackData: false }, - ); + const [isAtBottom, setIsAtBottom] = useState(true); const { data: scrollBehavior = false, mutate: setScrollBehavior } = useSWR('messages:should-scroll', null, { fallbackData: false }); + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + + // Check if we are within 100px of the bottom (like v0 does) + setIsAtBottom(scrollTop + clientHeight >= scrollHeight - 100); + }, []); + useEffect(() => { - if (scrollBehavior) { - endRef.current?.scrollIntoView({ behavior: scrollBehavior }); + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', handleScroll); + handleScroll(); // Check initial state + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }, [handleScroll]); + + useEffect(() => { + if (scrollBehavior && containerRef.current) { + const container = containerRef.current; + const scrollOptions: ScrollToOptions = { + top: container.scrollHeight, + behavior: scrollBehavior + }; + container.scrollTo(scrollOptions); setScrollBehavior(false); } - }, [setScrollBehavior, scrollBehavior]); + }, [scrollBehavior, setScrollBehavior]); const scrollToBottom = useCallback( (scrollBehavior: ScrollBehavior = 'smooth') => {