ui: mobile chatbot improvements (#1155)
This commit is contained in:
parent
fd8ed4d863
commit
31de38ab18
16 changed files with 547 additions and 449 deletions
|
|
@ -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,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 && (
|
||||
|
|
|
|||
|
|
@ -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,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue