ui: mobile chatbot improvements (#1155)
This commit is contained in:
parent
fd8ed4d863
commit
31de38ab18
16 changed files with 547 additions and 449 deletions
|
|
@ -20,6 +20,18 @@
|
||||||
.text-balance {
|
.text-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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,8 +33,6 @@ function PureChatHeader({
|
||||||
<SidebarToggle />
|
<SidebarToggle />
|
||||||
|
|
||||||
{(!open || windowWidth < 768) && (
|
{(!open || windowWidth < 768) && (
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="order-2 md:order-1 md:px-2 px-2 md:h-fit ml-auto md:ml-0"
|
className="order-2 md:order-1 md:px-2 px-2 md:h-fit ml-auto md:ml-0"
|
||||||
|
|
@ -47,9 +44,6 @@ function PureChatHeader({
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
<span className="md:sr-only">New Chat</span>
|
<span className="md:sr-only">New Chat</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>New Chat</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isReadonly && (
|
{!isReadonly && (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,29 +15,26 @@ 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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Actions>
|
|
||||||
<Action
|
|
||||||
tooltip="Copy"
|
|
||||||
onClick={async () => {
|
|
||||||
const textFromParts = message.parts
|
const textFromParts = message.parts
|
||||||
?.filter((part) => part.type === 'text')
|
?.filter((part) => part.type === 'text')
|
||||||
.map((part) => part.text)
|
.map((part) => part.text)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
if (!textFromParts) {
|
if (!textFromParts) {
|
||||||
toast.error("There's no text to copy!");
|
toast.error("There's no text to copy!");
|
||||||
return;
|
return;
|
||||||
|
|
@ -45,8 +42,33 @@ export function PureMessageActions({
|
||||||
|
|
||||||
await copyToClipboard(textFromParts);
|
await copyToClipboard(textFromParts);
|
||||||
toast.success('Copied to clipboard!');
|
toast.success('Copied to clipboard!');
|
||||||
}}
|
};
|
||||||
|
|
||||||
|
// User messages get edit (on hover) and copy actions
|
||||||
|
if (message.role === 'user') {
|
||||||
|
return (
|
||||||
|
<Actions className="justify-end -mr-0.5">
|
||||||
|
<div className="relative">
|
||||||
|
{setMode && (
|
||||||
|
<Action
|
||||||
|
tooltip="Edit"
|
||||||
|
onClick={() => setMode('edit')}
|
||||||
|
className="absolute top-0 -left-10 opacity-0 transition-opacity group-hover/message:opacity-100"
|
||||||
>
|
>
|
||||||
|
<PencilEditIcon />
|
||||||
|
</Action>
|
||||||
|
)}
|
||||||
|
<Action tooltip="Copy" onClick={handleCopy}>
|
||||||
|
<CopyIcon />
|
||||||
|
</Action>
|
||||||
|
</div>
|
||||||
|
</Actions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Actions className="-ml-0.5">
|
||||||
|
<Action tooltip="Copy" onClick={handleCopy}>
|
||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Action>
|
</Action>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,33 +55,39 @@ 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={{ y: 5, opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
data-role={message.role}
|
data-role={message.role}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('flex items-start gap-3', {
|
className={cn('flex items-start gap-3 w-full', {
|
||||||
'w-full': mode === 'edit',
|
'justify-end': message.role === 'user' && mode !== 'edit',
|
||||||
'max-w-xl ml-auto justify-end mr-6':
|
'justify-start': message.role === 'assistant',
|
||||||
message.role === 'user' && mode !== 'edit',
|
|
||||||
'justify-start -ml-3': message.role === 'assistant',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{message.role === 'assistant' && (
|
{message.role === 'assistant' && (
|
||||||
<div className="flex justify-center items-center mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
|
<div className="flex justify-center items-center -mt-1 rounded-full ring-1 size-8 shrink-0 ring-border bg-background">
|
||||||
<SparklesIcon size={14} />
|
<SparklesIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-col gap-4', {
|
className={cn('flex flex-col', {
|
||||||
|
'gap-2 md:gap-4': message.parts?.some(
|
||||||
|
(p) => p.type === 'text' && p.text?.trim(),
|
||||||
|
),
|
||||||
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
|
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
|
||||||
'w-full': message.role === 'assistant',
|
'w-full':
|
||||||
'w-fit': message.role === 'user',
|
(message.role === 'assistant' &&
|
||||||
|
message.parts?.some(
|
||||||
|
(p) => p.type === 'text' && p.text?.trim(),
|
||||||
|
)) ||
|
||||||
|
mode === 'edit',
|
||||||
|
'max-w-[90%] sm:max-w-[min(fit-content,80%)]':
|
||||||
|
message.role === 'user' && mode !== 'edit',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{attachmentsFromMessage.length > 0 && (
|
{attachmentsFromMessage.length > 0 && (
|
||||||
|
|
@ -125,32 +125,20 @@ const PurePreviewMessage = ({
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-row gap-2 items-start">
|
<div key={key}>
|
||||||
{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
|
<MessageContent
|
||||||
data-testid="message-content"
|
data-testid="message-content"
|
||||||
className={cn('justify-start items-start text-left', {
|
className={cn({
|
||||||
'bg-primary text-primary-foreground':
|
'rounded-2xl px-3 py-2 break-words text-white text-right w-fit':
|
||||||
message.role === 'user',
|
message.role === 'user',
|
||||||
'bg-transparent -ml-4': message.role === 'assistant',
|
'bg-transparent px-0 py-0 text-left':
|
||||||
|
message.role === 'assistant',
|
||||||
})}
|
})}
|
||||||
|
style={
|
||||||
|
message.role === 'user'
|
||||||
|
? { backgroundColor: '#006cff' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Response>{sanitizeText(part.text)}</Response>
|
<Response>{sanitizeText(part.text)}</Response>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
|
|
@ -289,12 +277,12 @@ const PurePreviewMessage = ({
|
||||||
message={message}
|
message={message}
|
||||||
vote={vote}
|
vote={vote}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
setMode={setMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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') => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue