ui: mobile chatbot improvements (#1155)
This commit is contained in:
parent
fd8ed4d863
commit
31de38ab18
16 changed files with 547 additions and 449 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function AppSidebar({ user }: { user: User | undefined }) {
|
|||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="p-2 h-fit"
|
||||
className="p-1 h-8 md:p-2 md:h-fit"
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
router.push('/');
|
||||
|
|
@ -53,7 +53,7 @@ export function AppSidebar({ user }: { user: User | undefined }) {
|
|||
<PlusIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end">New Chat</TooltipContent>
|
||||
<TooltipContent align="end" className="hidden md:block">New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
|
|
|
|||
|
|
@ -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,8 +33,6 @@ function PureChatHeader({
|
|||
<SidebarToggle />
|
||||
|
||||
{(!open || windowWidth < 768) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="order-2 md:order-1 md:px-2 px-2 md:h-fit ml-auto md:ml-0"
|
||||
|
|
@ -47,9 +44,6 @@ function PureChatHeader({
|
|||
<PlusIcon />
|
||||
<span className="md:sr-only">New Chat</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isReadonly && (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<div className="flex flex-col min-w-0 h-dvh bg-background">
|
||||
<div className="flex flex-col min-w-0 h-dvh bg-background touch-pan-y overscroll-behavior-contain">
|
||||
<ChatHeader
|
||||
chatId={id}
|
||||
selectedVisibilityType={initialVisibilityType}
|
||||
|
|
@ -145,9 +145,10 @@ export function Chat({
|
|||
regenerate={regenerate}
|
||||
isReadonly={isReadonly}
|
||||
isArtifactVisible={isArtifactVisible}
|
||||
selectedModelId={initialChatModel}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 flex gap-2 px-4 pb-4 mx-auto w-full bg-background md:pb-6 md:max-w-3xl z-[1] border-t-0">
|
||||
<div className="sticky bottom-0 flex gap-2 px-2 md:px-4 pb-3 md:pb-4 mx-auto w-full bg-background max-w-4xl z-[1] border-t-0">
|
||||
{!isReadonly && (
|
||||
<MultimodalInput
|
||||
chatId={id}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { ComponentProps } from 'react';
|
|||
export type ActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
||||
<div className={cn('flex gap-1 items-center', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
|||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
className={cn(
|
||||
'overflow-y-auto relative flex-1 touch-pan-y will-change-scroll',
|
||||
className,
|
||||
)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
|
|
@ -46,7 +49,7 @@ export const ConversationScrollButton = ({
|
|||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
|
||||
'absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full z-10 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|||
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 }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4', className)}
|
||||
className={cn('not-prose', className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
|
|
@ -119,7 +119,7 @@ export const ReasoningTrigger = memo(
|
|||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-muted-foreground text-sm',
|
||||
'flex items-center gap-1.5 text-muted-foreground text-xs hover:text-foreground transition-colors',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -130,11 +130,11 @@ export const ReasoningTrigger = memo(
|
|||
{isStreaming || duration === 0 ? (
|
||||
<p>Thinking...</p>
|
||||
) : (
|
||||
<p>Thought for {duration} seconds</p>
|
||||
<p>Thought for {duration}s</p>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform',
|
||||
'size-3 text-muted-foreground transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0',
|
||||
)}
|
||||
/>
|
||||
|
|
@ -155,8 +155,8 @@ export const ReasoningContent = memo(
|
|||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'mt-2 text-xs text-muted-foreground',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ export const Greeting = () => {
|
|||
return (
|
||||
<div
|
||||
key="overview"
|
||||
className="max-w-3xl mx-auto md:mt-20 px-8 size-full flex flex-col justify-center"
|
||||
className="max-w-3xl mx-auto mt-4 md:mt-16 px-4 md:px-8 size-full flex flex-col justify-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-2xl font-semibold"
|
||||
className="text-xl md:text-2xl font-semibold"
|
||||
>
|
||||
Hello there!
|
||||
</motion.div>
|
||||
|
|
@ -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?
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -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,29 +15,26 @@ 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;
|
||||
|
||||
return (
|
||||
<Actions>
|
||||
<Action
|
||||
tooltip="Copy"
|
||||
onClick={async () => {
|
||||
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;
|
||||
|
|
@ -45,8 +42,33 @@ export function PureMessageActions({
|
|||
|
||||
await copyToClipboard(textFromParts);
|
||||
toast.success('Copied to clipboard!');
|
||||
}}
|
||||
};
|
||||
|
||||
// User messages get edit (on hover) and copy actions
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<Actions className="justify-end -mr-0.5">
|
||||
<div className="relative">
|
||||
{setMode && (
|
||||
<Action
|
||||
tooltip="Edit"
|
||||
onClick={() => setMode('edit')}
|
||||
className="absolute top-0 -left-10 opacity-0 transition-opacity group-hover/message:opacity-100"
|
||||
>
|
||||
<PencilEditIcon />
|
||||
</Action>
|
||||
)}
|
||||
<Action tooltip="Copy" onClick={handleCopy}>
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
</div>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Actions className="-ml-0.5">
|
||||
<Action tooltip="Copy" onClick={handleCopy}>
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,33 +55,39 @@ const PurePreviewMessage = ({
|
|||
useDataStream();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
data-testid={`message-${message.role}`}
|
||||
className="w-full group/message"
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
data-role={message.role}
|
||||
>
|
||||
<div
|
||||
className={cn('flex items-start gap-3', {
|
||||
'w-full': mode === 'edit',
|
||||
'max-w-xl ml-auto justify-end mr-6':
|
||||
message.role === 'user' && mode !== 'edit',
|
||||
'justify-start -ml-3': message.role === 'assistant',
|
||||
className={cn('flex items-start gap-3 w-full', {
|
||||
'justify-end': message.role === 'user' && mode !== 'edit',
|
||||
'justify-start': message.role === 'assistant',
|
||||
})}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<div className="flex justify-center items-center mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
|
||||
<div className="flex justify-center items-center -mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn('flex flex-col gap-4', {
|
||||
className={cn('flex flex-col', {
|
||||
'gap-2 md:gap-4': message.parts?.some(
|
||||
(p) => p.type === 'text' && p.text?.trim(),
|
||||
),
|
||||
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
|
||||
'w-full': message.role === 'assistant',
|
||||
'w-fit': message.role === 'user',
|
||||
'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',
|
||||
})}
|
||||
>
|
||||
{attachmentsFromMessage.length > 0 && (
|
||||
|
|
@ -125,32 +125,20 @@ const PurePreviewMessage = ({
|
|||
if (type === 'text') {
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<div key={key} className="flex flex-row gap-2 items-start">
|
||||
{message.role === 'user' && !isReadonly && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-testid="message-edit-button"
|
||||
variant="ghost"
|
||||
className="px-2 rounded-full opacity-0 h-fit text-muted-foreground group-hover/message:opacity-100"
|
||||
onClick={() => {
|
||||
setMode('edit');
|
||||
}}
|
||||
>
|
||||
<PencilEditIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit message</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div key={key}>
|
||||
<MessageContent
|
||||
data-testid="message-content"
|
||||
className={cn('justify-start items-start text-left', {
|
||||
'bg-primary text-primary-foreground':
|
||||
className={cn({
|
||||
'rounded-2xl px-3 py-2 break-words text-white text-right w-fit':
|
||||
message.role === 'user',
|
||||
'bg-transparent -ml-4': message.role === 'assistant',
|
||||
'bg-transparent px-0 py-0 text-left':
|
||||
message.role === 'assistant',
|
||||
})}
|
||||
style={
|
||||
message.role === 'user'
|
||||
? { backgroundColor: '#006cff' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Response>{sanitizeText(part.text)}</Response>
|
||||
</MessageContent>
|
||||
|
|
@ -289,12 +277,12 @@ const PurePreviewMessage = ({
|
|||
message={message}
|
||||
vote={vote}
|
||||
isLoading={isLoading}
|
||||
setMode={setMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -319,21 +307,44 @@ export const ThinkingMessage = () => {
|
|||
<motion.div
|
||||
data-testid="message-assistant-loading"
|
||||
className="w-full group/message"
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
data-role={role}
|
||||
>
|
||||
<div className="flex items-start gap-3 justify-start -ml-3">
|
||||
<div className="flex justify-center items-center mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
|
||||
<div className="flex gap-3 justify-start items-start">
|
||||
<div className="flex justify-center items-center -mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<MessageContent className="bg-transparent -ml-4">
|
||||
<div className="text-muted-foreground">Hmm...</div>
|
||||
</MessageContent>
|
||||
<div className="flex flex-col gap-2 w-full md:gap-4">
|
||||
<div className="px-0 py-0 text-sm text-muted-foreground">
|
||||
<LoadingText>Thinking...</LoadingText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingText = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ backgroundPosition: ['100% 50%', '-100% 50%'] }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'linear',
|
||||
}}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 35%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 65%, hsl(var(--muted-foreground)) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
className="flex items-center text-transparent"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ChatMessage>['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 (
|
||||
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto">
|
||||
<Conversation className="flex flex-col min-w-0 gap-6 pt-4 pb-32 px-4 max-w-4xl mx-auto">
|
||||
<ConversationContent className="flex flex-col gap-6">
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="overflow-y-scroll flex-1 touch-pan-y overscroll-behavior-contain -webkit-overflow-scrolling-touch"
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
<Conversation className="flex flex-col gap-4 px-2 pt-4 pb-4 mx-auto min-w-0 max-w-4xl md:gap-6 md:px-4">
|
||||
<ConversationContent className="flex flex-col gap-4 md:gap-6">
|
||||
{messages.length === 0 && <Greeting />}
|
||||
|
||||
{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' && <ThinkingMessage />}
|
||||
messages[messages.length - 1].role === 'user' &&
|
||||
selectedModelId !== 'chat-model-reasoning' && (
|
||||
<ThinkingMessage />
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
onViewportLeave={onViewportLeave}
|
||||
onViewportEnter={onViewportEnter}
|
||||
/>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
className="absolute bottom-40 left-1/2 z-10 p-2 rounded-full border shadow-lg transition-colors -translate-x-1/2 bg-background hover:bg-muted"
|
||||
onClick={() => scrollToBottom('smooth')}
|
||||
type="button"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<Button
|
||||
data-testid="scroll-to-bottom-button"
|
||||
|
|
@ -264,7 +264,7 @@ function PureMultimodalInput({
|
|||
/>
|
||||
|
||||
<PromptInput
|
||||
className="bg-gray-50 rounded-3xl border border-gray-300 shadow-none transition-all duration-200 dark:bg-sidebar dark:border-sidebar-border hover:ring-1 hover:ring-primary/30 focus-within:ring-1 focus-within:ring-primary/50"
|
||||
className="rounded-xl border shadow-sm transition-all duration-200 bg-background border-border focus-within:border-border hover:border-muted-foreground/50"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (status !== 'ready') {
|
||||
|
|
@ -314,14 +314,14 @@ function PureMultimodalInput({
|
|||
placeholder="Send a message..."
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
minHeight={72}
|
||||
minHeight={44}
|
||||
maxHeight={200}
|
||||
disableAutoResize={true}
|
||||
className="text-base resize-none py-4 px-4 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] bg-transparent !border-0 !border-none outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none"
|
||||
className="text-sm resize-none py-3 px-3 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] bg-transparent !border-0 !border-none outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none placeholder:text-muted-foreground"
|
||||
rows={1}
|
||||
autoFocus
|
||||
/>
|
||||
<PromptInputToolbar className="px-4 py-2 !border-t-0 !border-top-0 shadow-none dark:!border-transparent dark:border-0">
|
||||
<PromptInputToolbar className="px-3 py-2 !border-t-0 !border-top-0 shadow-none dark:!border-transparent dark:border-0">
|
||||
<PromptInputTools className="gap-2">
|
||||
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
|
||||
<ModelSelectorCompact selectedModelId={selectedModelId} />
|
||||
|
|
@ -332,9 +332,9 @@ function PureMultimodalInput({
|
|||
<PromptInputSubmit
|
||||
status={status}
|
||||
disabled={!input.trim() || uploadQueue.length > 0}
|
||||
className="p-3 text-gray-700 bg-gray-200 rounded-full hover:bg-gray-300 dark:bg-sidebar-accent dark:hover:bg-sidebar-accent/80 dark:text-gray-300"
|
||||
className="p-2 rounded-full transition-colors duration-200 text-primary-foreground bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
>
|
||||
<ArrowUpIcon size={20} />
|
||||
<ArrowUpIcon size={16} />
|
||||
</PromptInputSubmit>
|
||||
)}
|
||||
</PromptInputToolbar>
|
||||
|
|
@ -367,13 +367,14 @@ function PureAttachmentsButton({
|
|||
return (
|
||||
<Button
|
||||
data-testid="attachments-button"
|
||||
className="rounded-md rounded-bl-lg p-[7px] h-fit dark:border-zinc-700 hover:dark:bg-zinc-900 hover:bg-zinc-200"
|
||||
className="rounded-md p-1.5 h-fit hover:bg-muted transition-colors duration-200"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
disabled={status !== 'ready'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<PaperclipIcon size={14} />
|
||||
</Button>
|
||||
|
|
@ -440,47 +441,18 @@ function PureStopButton({
|
|||
return (
|
||||
<Button
|
||||
data-testid="stop-button"
|
||||
className="rounded-full p-1.5 h-fit border dark:border-zinc-600"
|
||||
className="p-2 rounded-full border transition-colors duration-200 h-fit border-border hover:bg-muted"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
stop();
|
||||
setMessages((messages) => messages);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
<StopIcon size={16} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const StopButton = memo(PureStopButton);
|
||||
|
||||
function PureSendButton({
|
||||
submitForm,
|
||||
input,
|
||||
uploadQueue,
|
||||
}: {
|
||||
submitForm: () => void;
|
||||
input: string;
|
||||
uploadQueue: Array<string>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="send-button"
|
||||
className="rounded-full p-1.5 h-fit border dark:border-zinc-600"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
submitForm();
|
||||
}}
|
||||
disabled={input.length === 0 || uploadQueue.length > 0}
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const SendButton = memo(PureSendButton, (prevProps, nextProps) => {
|
||||
if (prevProps.uploadQueue.length !== nextProps.uploadQueue.length)
|
||||
return false;
|
||||
if (prevProps.input !== nextProps.input) return false;
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<SidebarLeftIcon size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start">Toggle Sidebar</TooltipContent>
|
||||
<TooltipContent align="start" className="hidden md:block">Toggle Sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const endRef = useRef<HTMLDivElement>(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<ScrollFlag>('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') => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue