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

@ -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>

View file

@ -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({
<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"
onClick={() => {
router.push('/');
router.refresh();
}}
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
</Button>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
<Button
variant="outline"
className="order-2 md:order-1 md:px-2 px-2 md:h-fit ml-auto md:ml-0"
onClick={() => {
router.push('/');
router.refresh();
}}
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
</Button>
)}
{!isReadonly && (

View file

@ -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}

View file

@ -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>
);

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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 (
<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>
<Action
tooltip="Copy"
onClick={async () => {
const textFromParts = message.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n')
.trim();
<Actions className="-ml-0.5">
<Action tooltip="Copy" onClick={handleCopy}>
<CopyIcon />
</Action>
if (!textFromParts) {
toast.error("There's no text to copy!");
return;
}
<Action
tooltip="Upvote Response"
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.success('Copied to clipboard!');
}}
>
<CopyIcon />
</Action>
toast.promise(upvote, {
loading: 'Upvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
<Action
tooltip="Upvote Response"
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',
}),
});
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id,
);
toast.promise(upvote, {
loading: 'Upvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/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.',
});
}}
>
<ThumbUpIcon />
</Action>
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: true,
},
];
},
{ revalidate: false },
);
<Action
tooltip="Downvote Response"
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',
}),
});
return 'Upvoted Response!';
},
error: 'Failed to upvote response.',
});
}}
>
<ThumbUpIcon />
</Action>
toast.promise(downvote, {
loading: 'Downvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
<Action
tooltip="Downvote Response"
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',
}),
});
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id,
);
toast.promise(downvote, {
loading: 'Downvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/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.',
});
}}
>
<ThumbDownIcon />
</Action>
return 'Downvoted Response!';
},
error: 'Failed to downvote response.',
});
}}
>
<ThumbDownIcon />
</Action>
</Actions>
);
}

View file

@ -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 (
<AnimatePresence>
<motion.div
data-testid={`message-${message.role}`}
className="w-full group/message"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
data-role={message.role}
<motion.div
data-testid={`message-${message.role}`}
className="w-full group/message"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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
className={cn('flex items-start gap-3', {
'w-full': mode === 'edit',
'max-w-xl ml-auto justify-end mr-6':
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' &&
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' && (
<div className="flex justify-center items-center mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
<SparklesIcon size={14} />
{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>
)}
<div
className={cn('flex flex-col gap-4', {
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
'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) => {
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 (
<MessageReasoning
key={key}
isLoading={isLoading}
reasoning={part.text}
/>
);
}
if (type === 'reasoning' && part.text?.trim().length > 0) {
if (type === 'text') {
if (mode === 'view') {
return (
<MessageReasoning
key={key}
isLoading={isLoading}
reasoning={part.text}
/>
);
}
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>
)}
<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 key={key}>
<MessageContent
data-testid="message-content"
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',
})}
style={
message.role === 'user'
? { backgroundColor: '#006cff' }
: undefined
}
>
<div className="size-8" />
<div className="flex-1 min-w-0">
<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 }}
/>
<Response>{sanitizeText(part.text)}</Response>
</MessageContent>
</div>
);
}
if (type === 'tool-requestSuggestions') {
const { toolCallId, state } = part;
if (mode === 'edit') {
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>
<div
key={key}
className="flex flex-row gap-3 items-start w-full"
>
<div className="size-8" />
<div className="flex-1 min-w-0">
<MessageEditor
key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
regenerate={regenerate}
/>
</div>
</div>
);
}
})}
}
{!isReadonly && (
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
/>
)}
</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>
);
}
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>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
);
};
@ -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>
);
};

View file

@ -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;

View file

@ -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;
});

View file

@ -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>
);
}

View file

@ -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>