ui: mobile chatbot improvements (#1155)

This commit is contained in:
josh 2025-09-07 00:04:51 +01:00 committed by GitHub
parent fd8ed4d863
commit 31de38ab18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 547 additions and 449 deletions

View file

@ -20,6 +20,18 @@
.text-balance { .text-balance {
text-wrap: 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 { @layer base {
@ -101,6 +113,12 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
overflow-x: hidden;
position: relative;
}
html {
overflow-x: hidden;
} }
} }
@ -162,3 +180,33 @@
.suggestion-highlight { .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; @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;
}

View file

@ -43,7 +43,7 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
className="p-2 h-fit" className="p-1 h-8 md:p-2 md:h-fit"
onClick={() => { onClick={() => {
setOpenMobile(false); setOpenMobile(false);
router.push('/'); router.push('/');
@ -53,7 +53,7 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<PlusIcon /> <PlusIcon />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent align="end">New Chat</TooltipContent> <TooltipContent align="end" className="hidden md:block">New Chat</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</SidebarMenu> </SidebarMenu>

View file

@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button';
import { PlusIcon, VercelIcon } from './icons'; import { PlusIcon, VercelIcon } from './icons';
import { useSidebar } from './ui/sidebar'; import { useSidebar } from './ui/sidebar';
import { memo } from 'react'; import { memo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { type VisibilityType, VisibilitySelector } from './visibility-selector'; import { type VisibilityType, VisibilitySelector } from './visibility-selector';
import type { Session } from 'next-auth'; import type { Session } from 'next-auth';
@ -34,22 +33,17 @@ function PureChatHeader({
<SidebarToggle /> <SidebarToggle />
{(!open || windowWidth < 768) && ( {(!open || windowWidth < 768) && (
<Tooltip> <Button
<TooltipTrigger asChild> variant="outline"
<Button className="order-2 md:order-1 md:px-2 px-2 md:h-fit ml-auto md:ml-0"
variant="outline" onClick={() => {
className="order-2 md:order-1 md:px-2 px-2 md:h-fit ml-auto md:ml-0" router.push('/');
onClick={() => { router.refresh();
router.push('/'); }}
router.refresh(); >
}} <PlusIcon />
> <span className="md:sr-only">New Chat</span>
<PlusIcon /> </Button>
<span className="md:sr-only">New Chat</span>
</Button>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
)} )}
{!isReadonly && ( {!isReadonly && (

View file

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import useSWR, { useSWRConfig } from 'swr'; import useSWR, { useSWRConfig } from 'swr';
import { ChatHeader } from '@/components/chat-header'; import { ChatHeader } from '@/components/chat-header';
import type { Vote } from '@/lib/db/schema'; 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 { Artifact } from './artifact';
import { MultimodalInput } from './multimodal-input'; import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages'; import { Messages } from './messages';
@ -128,7 +128,7 @@ export function Chat({
return ( 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 <ChatHeader
chatId={id} chatId={id}
selectedVisibilityType={initialVisibilityType} selectedVisibilityType={initialVisibilityType}
@ -145,9 +145,10 @@ export function Chat({
regenerate={regenerate} regenerate={regenerate}
isReadonly={isReadonly} isReadonly={isReadonly}
isArtifactVisible={isArtifactVisible} 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 && ( {!isReadonly && (
<MultimodalInput <MultimodalInput
chatId={id} chatId={id}

View file

@ -13,7 +13,7 @@ import type { ComponentProps } from 'react';
export type ActionsProps = ComponentProps<'div'>; export type ActionsProps = ComponentProps<'div'>;
export const Actions = ({ className, children, ...props }: ActionsProps) => ( 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} {children}
</div> </div>
); );

View file

@ -11,7 +11,10 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => ( export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom <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" initial="smooth"
resize="smooth" resize="smooth"
role="log" role="log"
@ -46,7 +49,7 @@ export const ConversationScrollButton = ({
!isAtBottom && ( !isAtBottom && (
<Button <Button
className={cn( 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, className,
)} )}
onClick={handleScrollToBottom} onClick={handleScrollToBottom}

View file

@ -37,7 +37,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
duration?: number; duration?: number;
}; };
const AUTO_CLOSE_DELAY = 1000; const AUTO_CLOSE_DELAY = 500;
const MS_IN_S = 1000; const MS_IN_S = 1000;
export const Reasoning = memo( export const Reasoning = memo(
@ -98,7 +98,7 @@ export const Reasoning = memo(
value={{ isStreaming, isOpen, setIsOpen, duration }} value={{ isStreaming, isOpen, setIsOpen, duration }}
> >
<Collapsible <Collapsible
className={cn('not-prose mb-4', className)} className={cn('not-prose', className)}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
open={isOpen} open={isOpen}
{...props} {...props}
@ -119,7 +119,7 @@ export const ReasoningTrigger = memo(
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( 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, className,
)} )}
{...props} {...props}
@ -130,11 +130,11 @@ export const ReasoningTrigger = memo(
{isStreaming || duration === 0 ? ( {isStreaming || duration === 0 ? (
<p>Thinking...</p> <p>Thinking...</p>
) : ( ) : (
<p>Thought for {duration} seconds</p> <p>Thought for {duration}s</p>
)} )}
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
'size-4 text-muted-foreground transition-transform', 'size-3 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0', isOpen ? 'rotate-180' : 'rotate-0',
)} )}
/> />
@ -155,8 +155,8 @@ export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => ( ({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
'mt-4 text-sm', '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 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in', '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, className,
)} )}
{...props} {...props}

View file

@ -4,14 +4,14 @@ export const Greeting = () => {
return ( return (
<div <div
key="overview" 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 <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="text-2xl font-semibold" className="text-xl md:text-2xl font-semibold"
> >
Hello there! Hello there!
</motion.div> </motion.div>
@ -20,7 +20,7 @@ export const Greeting = () => {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }} 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? How can I help you today?
</motion.div> </motion.div>

View file

@ -3,7 +3,7 @@ import { useCopyToClipboard } from 'usehooks-ts';
import type { Vote } from '@/lib/db/schema'; 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 { Actions, Action } from './elements/actions';
import { memo } from 'react'; import { memo } from 'react';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
@ -15,134 +15,156 @@ export function PureMessageActions({
message, message,
vote, vote,
isLoading, isLoading,
setMode,
}: { }: {
chatId: string; chatId: string;
message: ChatMessage; message: ChatMessage;
vote: Vote | undefined; vote: Vote | undefined;
isLoading: boolean; isLoading: boolean;
setMode?: (mode: 'view' | 'edit') => void;
}) { }) {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard(); const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) return null; 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 (
<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 ( return (
<Actions> <Actions className="-ml-0.5">
<Action <Action tooltip="Copy" onClick={handleCopy}>
tooltip="Copy" <CopyIcon />
onClick={async () => { </Action>
const textFromParts = message.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n')
.trim();
if (!textFromParts) { <Action
toast.error("There's no text to copy!"); tooltip="Upvote Response"
return; data-testid="message-upvote"
} disabled={vote?.isUpvoted}
onClick={async () => {
const upvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId: message.id,
type: 'up',
}),
});
await copyToClipboard(textFromParts); toast.promise(upvote, {
toast.success('Copied to clipboard!'); loading: 'Upvoting Response...',
}} success: () => {
> mutate<Array<Vote>>(
<CopyIcon /> `/api/vote?chatId=${chatId}`,
</Action> (currentVotes) => {
if (!currentVotes) return [];
<Action const votesWithoutCurrent = currentVotes.filter(
tooltip="Upvote Response" (vote) => vote.messageId !== message.id,
data-testid="message-upvote" );
disabled={vote?.isUpvoted}
onClick={async () => {
const upvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId: message.id,
type: 'up',
}),
});
toast.promise(upvote, { return [
loading: 'Upvoting Response...', ...votesWithoutCurrent,
success: () => { {
mutate<Array<Vote>>( chatId,
`/api/vote?chatId=${chatId}`, messageId: message.id,
(currentVotes) => { isUpvoted: true,
if (!currentVotes) return []; },
];
},
{ revalidate: false },
);
const votesWithoutCurrent = currentVotes.filter( return 'Upvoted Response!';
(vote) => vote.messageId !== message.id, },
); error: 'Failed to upvote response.',
});
}}
>
<ThumbUpIcon />
</Action>
return [ <Action
...votesWithoutCurrent, tooltip="Downvote Response"
{ data-testid="message-downvote"
chatId, disabled={vote && !vote.isUpvoted}
messageId: message.id, onClick={async () => {
isUpvoted: true, const downvote = fetch('/api/vote', {
}, method: 'PATCH',
]; body: JSON.stringify({
}, chatId,
{ revalidate: false }, messageId: message.id,
); type: 'down',
}),
});
return 'Upvoted Response!'; toast.promise(downvote, {
}, loading: 'Downvoting Response...',
error: 'Failed to upvote response.', success: () => {
}); mutate<Array<Vote>>(
}} `/api/vote?chatId=${chatId}`,
> (currentVotes) => {
<ThumbUpIcon /> if (!currentVotes) return [];
</Action>
<Action const votesWithoutCurrent = currentVotes.filter(
tooltip="Downvote Response" (vote) => vote.messageId !== message.id,
data-testid="message-downvote" );
disabled={vote && !vote.isUpvoted}
onClick={async () => {
const downvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId: message.id,
type: 'down',
}),
});
toast.promise(downvote, { return [
loading: 'Downvoting Response...', ...votesWithoutCurrent,
success: () => { {
mutate<Array<Vote>>( chatId,
`/api/vote?chatId=${chatId}`, messageId: message.id,
(currentVotes) => { isUpvoted: false,
if (!currentVotes) return []; },
];
},
{ revalidate: false },
);
const votesWithoutCurrent = currentVotes.filter( return 'Downvoted Response!';
(vote) => vote.messageId !== message.id, },
); error: 'Failed to downvote response.',
});
return [ }}
...votesWithoutCurrent, >
{ <ThumbDownIcon />
chatId, </Action>
messageId: message.id,
isUpvoted: false,
},
];
},
{ revalidate: false },
);
return 'Downvoted Response!';
},
error: 'Failed to downvote response.',
});
}}
>
<ThumbDownIcon />
</Action>
</Actions> </Actions>
); );
} }

View file

@ -1,10 +1,9 @@
'use client'; 'use client';
import cx from 'classnames'; import { motion } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import type { Vote } from '@/lib/db/schema'; import type { Vote } from '@/lib/db/schema';
import { DocumentToolResult } from './document'; import { DocumentToolResult } from './document';
import { PencilEditIcon, SparklesIcon, LoaderIcon } from './icons'; import { SparklesIcon } from './icons';
import { Response } from './elements/response'; import { Response } from './elements/response';
import { MessageContent } from './elements/message'; import { MessageContent } from './elements/message';
import { import {
@ -19,8 +18,6 @@ import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather'; import { Weather } from './weather';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
import { cn, sanitizeText } from '@/lib/utils'; import { cn, sanitizeText } from '@/lib/utils';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { MessageEditor } from './message-editor'; import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview'; import { DocumentPreview } from './document-preview';
import { MessageReasoning } from './message-reasoning'; import { MessageReasoning } from './message-reasoning';
@ -28,9 +25,6 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@/lib/types'; import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider'; 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 = ({ const PurePreviewMessage = ({
chatId, chatId,
message, message,
@ -61,240 +55,234 @@ const PurePreviewMessage = ({
useDataStream(); useDataStream();
return ( return (
<AnimatePresence> <motion.div
<motion.div data-testid={`message-${message.role}`}
data-testid={`message-${message.role}`} className="w-full group/message"
className="w-full group/message" initial={{ opacity: 0 }}
initial={{ y: 5, opacity: 0 }} animate={{ opacity: 1 }}
animate={{ y: 0, opacity: 1 }} data-role={message.role}
data-role={message.role} >
<div
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">
<SparklesIcon size={14} />
</div>
)}
<div <div
className={cn('flex items-start gap-3', { className={cn('flex flex-col', {
'w-full': mode === 'edit', 'gap-2 md:gap-4': message.parts?.some(
'max-w-xl ml-auto justify-end mr-6': (p) => 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', message.role === 'user' && mode !== 'edit',
'justify-start -ml-3': message.role === 'assistant',
})} })}
> >
{message.role === 'assistant' && ( {attachmentsFromMessage.length > 0 && (
<div className="flex justify-center items-center mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background"> <div
<SparklesIcon size={14} /> data-testid={`message-attachments`}
className="flex flex-row gap-2 justify-end"
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
key={attachment.url}
attachment={{
name: attachment.filename ?? 'file',
contentType: attachment.mediaType,
url: attachment.url,
}}
/>
))}
</div> </div>
)} )}
<div {message.parts?.map((part, index) => {
className={cn('flex flex-col gap-4', { const { type } = part;
'min-h-96': message.role === 'assistant' && requiresScrollPadding, const key = `message-${message.id}-part-${index}`;
'w-full': message.role === 'assistant',
'w-fit': message.role === 'user',
})}
>
{attachmentsFromMessage.length > 0 && (
<div
data-testid={`message-attachments`}
className="flex flex-row gap-2 justify-end"
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
key={attachment.url}
attachment={{
name: attachment.filename ?? 'file',
contentType: attachment.mediaType,
url: attachment.url,
}}
/>
))}
</div>
)}
{message.parts?.map((part, index) => { if (type === 'reasoning' && part.text?.trim().length > 0) {
const { type } = part; return (
const key = `message-${message.id}-part-${index}`; <MessageReasoning
key={key}
isLoading={isLoading}
reasoning={part.text}
/>
);
}
if (type === 'reasoning' && part.text?.trim().length > 0) { if (type === 'text') {
if (mode === 'view') {
return ( return (
<MessageReasoning <div key={key}>
key={key} <MessageContent
isLoading={isLoading} data-testid="message-content"
reasoning={part.text} className={cn({
/> 'rounded-2xl px-3 py-2 break-words text-white text-right w-fit':
); message.role === 'user',
} 'bg-transparent px-0 py-0 text-left':
message.role === 'assistant',
if (type === 'text') { })}
if (mode === 'view') { style={
return ( message.role === 'user'
<div key={key} className="flex flex-row gap-2 items-start"> ? { backgroundColor: '#006cff' }
{message.role === 'user' && !isReadonly && ( : undefined
<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>
)}
<MessageContent
data-testid="message-content"
className={cn('justify-start items-start text-left', {
'bg-primary text-primary-foreground':
message.role === 'user',
'bg-transparent -ml-4': message.role === 'assistant',
})}
>
<Response>{sanitizeText(part.text)}</Response>
</MessageContent>
</div>
);
}
if (mode === 'edit') {
return (
<div
key={key}
className="flex flex-row gap-3 items-start w-full"
> >
<div className="size-8" /> <Response>{sanitizeText(part.text)}</Response>
<div className="flex-1 min-w-0"> </MessageContent>
<MessageEditor
key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
regenerate={regenerate}
/>
</div>
</div>
);
}
}
if (type === 'tool-getWeather') {
const { toolCallId, state } = part;
return (
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-getWeather" state={state} />
<ToolContent>
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
<ToolOutput
output={<Weather weatherAtLocation={part.output} />}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
if (type === 'tool-createDocument') {
const { toolCallId } = part;
if (part.output && 'error' in part.output) {
return (
<div
key={toolCallId}
className="p-4 text-red-500 bg-red-50 rounded-lg border border-red-200 dark:bg-red-950/50"
>
Error creating document: {String(part.output.error)}
</div>
);
}
return (
<DocumentPreview
key={toolCallId}
isReadonly={isReadonly}
result={part.output}
/>
);
}
if (type === 'tool-updateDocument') {
const { toolCallId } = part;
if (part.output && 'error' in part.output) {
return (
<div
key={toolCallId}
className="p-4 text-red-500 bg-red-50 rounded-lg border border-red-200 dark:bg-red-950/50"
>
Error updating document: {String(part.output.error)}
</div>
);
}
return (
<div key={toolCallId} className="relative">
<DocumentPreview
isReadonly={isReadonly}
result={part.output}
args={{ ...part.output, isUpdate: true }}
/>
</div> </div>
); );
} }
if (type === 'tool-requestSuggestions') { if (mode === 'edit') {
const { toolCallId, state } = part;
return ( return (
<Tool key={toolCallId} defaultOpen={true}> <div
<ToolHeader type="tool-requestSuggestions" state={state} /> key={key}
<ToolContent> className="flex flex-row gap-3 items-start w-full"
{state === 'input-available' && ( >
<ToolInput input={part.input} /> <div className="size-8" />
)} <div className="flex-1 min-w-0">
{state === 'output-available' && ( <MessageEditor
<ToolOutput key={message.id}
output={ message={message}
'error' in part.output ? ( setMode={setMode}
<div className="p-2 text-red-500 rounded border"> setMessages={setMessages}
Error: {String(part.output.error)} regenerate={regenerate}
</div> />
) : ( </div>
<DocumentToolResult </div>
type="request-suggestions"
result={part.output}
isReadonly={isReadonly}
/>
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
); );
} }
})} }
{!isReadonly && ( if (type === 'tool-getWeather') {
<MessageActions const { toolCallId, state } = part;
key={`action-${message.id}`}
chatId={chatId} return (
message={message} <Tool key={toolCallId} defaultOpen={true}>
vote={vote} <ToolHeader type="tool-getWeather" state={state} />
isLoading={isLoading} <ToolContent>
/> {state === 'input-available' && (
)} <ToolInput input={part.input} />
</div> )}
{state === 'output-available' && (
<ToolOutput
output={<Weather weatherAtLocation={part.output} />}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
if (type === 'tool-createDocument') {
const { toolCallId } = part;
if (part.output && 'error' in part.output) {
return (
<div
key={toolCallId}
className="p-4 text-red-500 bg-red-50 rounded-lg border border-red-200 dark:bg-red-950/50"
>
Error creating document: {String(part.output.error)}
</div>
);
}
return (
<DocumentPreview
key={toolCallId}
isReadonly={isReadonly}
result={part.output}
/>
);
}
if (type === 'tool-updateDocument') {
const { toolCallId } = part;
if (part.output && 'error' in part.output) {
return (
<div
key={toolCallId}
className="p-4 text-red-500 bg-red-50 rounded-lg border border-red-200 dark:bg-red-950/50"
>
Error updating document: {String(part.output.error)}
</div>
);
}
return (
<div key={toolCallId} className="relative">
<DocumentPreview
isReadonly={isReadonly}
result={part.output}
args={{ ...part.output, isUpdate: true }}
/>
</div>
);
}
if (type === 'tool-requestSuggestions') {
const { toolCallId, state } = part;
return (
<Tool key={toolCallId} defaultOpen={true}>
<ToolHeader type="tool-requestSuggestions" state={state} />
<ToolContent>
{state === 'input-available' && (
<ToolInput input={part.input} />
)}
{state === 'output-available' && (
<ToolOutput
output={
'error' in part.output ? (
<div className="p-2 text-red-500 rounded border">
Error: {String(part.output.error)}
</div>
) : (
<DocumentToolResult
type="request-suggestions"
result={part.output}
isReadonly={isReadonly}
/>
)
}
errorText={undefined}
/>
)}
</ToolContent>
</Tool>
);
}
})}
{!isReadonly && (
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
setMode={setMode}
/>
)}
</div> </div>
</motion.div> </div>
</AnimatePresence> </motion.div>
); );
}; };
@ -319,21 +307,44 @@ export const ThinkingMessage = () => {
<motion.div <motion.div
data-testid="message-assistant-loading" data-testid="message-assistant-loading"
className="w-full group/message" className="w-full group/message"
initial={{ y: 5, opacity: 0 }} initial={{ opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }} animate={{ opacity: 1 }}
data-role={role} data-role={role}
> >
<div className="flex items-start gap-3 justify-start -ml-3"> <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"> <div className="flex justify-center items-center -mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
<SparklesIcon size={14} /> <SparklesIcon size={14} />
</div> </div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-2 w-full md:gap-4">
<MessageContent className="bg-transparent -ml-4"> <div className="px-0 py-0 text-sm text-muted-foreground">
<div className="text-muted-foreground">Hmm...</div> <LoadingText>Thinking...</LoadingText>
</MessageContent> </div>
</div> </div>
</div> </div>
</motion.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>
);
};

View file

@ -1,15 +1,14 @@
import { PreviewMessage, ThinkingMessage } from './message'; import { PreviewMessage, ThinkingMessage } from './message';
import { Greeting } from './greeting'; import { Greeting } from './greeting';
import { memo } from 'react'; import { memo, useEffect } from 'react';
import type { Vote } from '@/lib/db/schema'; import type { Vote } from '@/lib/db/schema';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react'; import type { UseChatHelpers } from '@ai-sdk/react';
import { motion } from 'framer-motion';
import { useMessages } from '@/hooks/use-messages'; import { useMessages } from '@/hooks/use-messages';
import type { ChatMessage } from '@/lib/types'; import type { ChatMessage } from '@/lib/types';
import { useDataStream } from './data-stream-provider'; import { useDataStream } from './data-stream-provider';
import { Conversation, ConversationContent, ConversationScrollButton } from './elements/conversation'; import { Conversation, ConversationContent } from './elements/conversation';
import { cn } from '@/lib/utils'; import { ArrowDownIcon } from 'lucide-react';
interface MessagesProps { interface MessagesProps {
chatId: string; chatId: string;
@ -20,6 +19,7 @@ interface MessagesProps {
regenerate: UseChatHelpers<ChatMessage>['regenerate']; regenerate: UseChatHelpers<ChatMessage>['regenerate'];
isReadonly: boolean; isReadonly: boolean;
isArtifactVisible: boolean; isArtifactVisible: boolean;
selectedModelId: string;
} }
function PureMessages({ function PureMessages({
@ -31,12 +31,13 @@ function PureMessages({
regenerate, regenerate,
isReadonly, isReadonly,
isArtifactVisible, isArtifactVisible,
selectedModelId,
}: MessagesProps) { }: MessagesProps) {
const { const {
containerRef: messagesContainerRef, containerRef: messagesContainerRef,
endRef: messagesEndRef, endRef: messagesEndRef,
onViewportEnter, isAtBottom,
onViewportLeave, scrollToBottom,
hasSentMessage, hasSentMessage,
} = useMessages({ } = useMessages({
chatId, chatId,
@ -45,10 +46,28 @@ function PureMessages({
useDataStream(); useDataStream();
useEffect(() => {
if (status === 'submitted') {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
});
}
}, [status]);
return ( return (
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto"> <div
<Conversation className="flex flex-col min-w-0 gap-6 pt-4 pb-32 px-4 max-w-4xl mx-auto"> ref={messagesContainerRef}
<ConversationContent className="flex flex-col gap-6"> 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.length === 0 && <Greeting />}
{messages.map((message, index) => ( {messages.map((message, index) => (
@ -56,7 +75,9 @@ function PureMessages({
key={message.id} key={message.id}
chatId={chatId} chatId={chatId}
message={message} message={message}
isLoading={status === 'streaming' && messages.length - 1 === index} isLoading={
status === 'streaming' && messages.length - 1 === index
}
vote={ vote={
votes votes
? votes.find((vote) => vote.messageId === message.id) ? votes.find((vote) => vote.messageId === message.id)
@ -74,17 +95,28 @@ function PureMessages({
{status === 'submitted' && {status === 'submitted' &&
messages.length > 0 && 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} ref={messagesEndRef}
className="shrink-0 min-w-[24px] min-h-[24px]" className="shrink-0 min-w-[24px] min-h-[24px]"
onViewportLeave={onViewportLeave}
onViewportEnter={onViewportEnter}
/> />
</ConversationContent> </ConversationContent>
<ConversationScrollButton />
</Conversation> </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> </div>
); );
} }
@ -93,6 +125,7 @@ export const Messages = memo(PureMessages, (prevProps, nextProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true; if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true;
if (prevProps.status !== nextProps.status) return false; if (prevProps.status !== nextProps.status) return false;
if (prevProps.selectedModelId !== nextProps.selectedModelId) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false; if (prevProps.messages.length !== nextProps.messages.length) return false;
if (!equal(prevProps.messages, nextProps.messages)) return false; if (!equal(prevProps.messages, nextProps.messages)) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false; if (!equal(prevProps.votes, nextProps.votes)) return false;

View file

@ -80,13 +80,13 @@ function PureMultimodalInput({
const adjustHeight = () => { const adjustHeight = () => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = '72px'; textareaRef.current.style.height = '44px';
} }
}; };
const resetHeight = () => { const resetHeight = () => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = '72px'; textareaRef.current.style.height = '44px';
} }
}; };
@ -226,7 +226,7 @@ function PureMultimodalInput({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }} 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 <Button
data-testid="scroll-to-bottom-button" data-testid="scroll-to-bottom-button"
@ -264,7 +264,7 @@ function PureMultimodalInput({
/> />
<PromptInput <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) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (status !== 'ready') { if (status !== 'ready') {
@ -314,14 +314,14 @@ function PureMultimodalInput({
placeholder="Send a message..." placeholder="Send a message..."
value={input} value={input}
onChange={handleInput} onChange={handleInput}
minHeight={72} minHeight={44}
maxHeight={200} maxHeight={200}
disableAutoResize={true} 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} rows={1}
autoFocus 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"> <PromptInputTools className="gap-2">
<AttachmentsButton fileInputRef={fileInputRef} status={status} /> <AttachmentsButton fileInputRef={fileInputRef} status={status} />
<ModelSelectorCompact selectedModelId={selectedModelId} /> <ModelSelectorCompact selectedModelId={selectedModelId} />
@ -332,9 +332,9 @@ function PureMultimodalInput({
<PromptInputSubmit <PromptInputSubmit
status={status} status={status}
disabled={!input.trim() || uploadQueue.length > 0} 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> </PromptInputSubmit>
)} )}
</PromptInputToolbar> </PromptInputToolbar>
@ -367,13 +367,14 @@ function PureAttachmentsButton({
return ( return (
<Button <Button
data-testid="attachments-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) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
fileInputRef.current?.click(); fileInputRef.current?.click();
}} }}
disabled={status !== 'ready'} disabled={status !== 'ready'}
variant="ghost" variant="ghost"
size="sm"
> >
<PaperclipIcon size={14} /> <PaperclipIcon size={14} />
</Button> </Button>
@ -440,47 +441,18 @@ function PureStopButton({
return ( return (
<Button <Button
data-testid="stop-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) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
stop(); stop();
setMessages((messages) => messages); setMessages((messages) => messages);
}} }}
variant="outline"
size="sm"
> >
<StopIcon size={14} /> <StopIcon size={16} />
</Button> </Button>
); );
} }
const StopButton = memo(PureStopButton); 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;
});

View file

@ -22,12 +22,12 @@ export function SidebarToggle({
data-testid="sidebar-toggle-button" data-testid="sidebar-toggle-button"
onClick={toggleSidebar} onClick={toggleSidebar}
variant="outline" variant="outline"
className="md:px-2 md:h-fit" className="px-1 h-8 md:px-2 md:h-fit"
> >
<SidebarLeftIcon size={16} /> <SidebarLeftIcon size={16} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent align="start">Toggle Sidebar</TooltipContent> <TooltipContent align="start" className="hidden md:block">Toggle Sidebar</TooltipContent>
</Tooltip> </Tooltip>
); );
} }

View file

@ -10,7 +10,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator'; 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 { Skeleton } from '@/components/ui/skeleton';
import { import {
Tooltip, Tooltip,
@ -214,6 +214,7 @@ const Sidebar = React.forwardRef<
} }
side={side} side={side}
> >
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View file

@ -21,13 +21,6 @@ export function useMessages({
const [hasSentMessage, setHasSentMessage] = useState(false); const [hasSentMessage, setHasSentMessage] = useState(false);
useEffect(() => {
if (chatId) {
scrollToBottom('instant');
setHasSentMessage(false);
}
}, [chatId, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (status === 'submitted') { if (status === 'submitted') {
setHasSentMessage(true); setHasSentMessage(true);

View file

@ -1,27 +1,47 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { useRef, useEffect, useCallback } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
type ScrollFlag = ScrollBehavior | false; type ScrollFlag = ScrollBehavior | false;
export function useScrollToBottom() { export function useScrollToBottom() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const { data: isAtBottom = false, mutate: setIsAtBottom } = useSWR(
'messages:is-at-bottom',
null,
{ fallbackData: false },
);
const { data: scrollBehavior = false, mutate: setScrollBehavior } = const { data: scrollBehavior = false, mutate: setScrollBehavior } =
useSWR<ScrollFlag>('messages:should-scroll', null, { fallbackData: false }); 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(() => { useEffect(() => {
if (scrollBehavior) { const container = containerRef.current;
endRef.current?.scrollIntoView({ behavior: scrollBehavior }); 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(false);
} }
}, [setScrollBehavior, scrollBehavior]); }, [scrollBehavior, setScrollBehavior]);
const scrollToBottom = useCallback( const scrollToBottom = useCallback(
(scrollBehavior: ScrollBehavior = 'smooth') => { (scrollBehavior: ScrollBehavior = 'smooth') => {