mirror of
https://github.com/cfngc4594/monaco-editor-lsp-next.git
synced 2025-05-18 15:26:36 +00:00
feat(cli): add all components with shadcn-chat-cli
This commit is contained in:
parent
cc3fb8afc4
commit
2b4a4b527e
202
src/components/ui/chat/chat-bubble.tsx
Normal file
202
src/components/ui/chat/chat-bubble.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import MessageLoading from "./message-loading";
|
||||||
|
import { Button, ButtonProps } from "../button";
|
||||||
|
|
||||||
|
// ChatBubble
|
||||||
|
const chatBubbleVariant = cva(
|
||||||
|
"flex gap-2 max-w-[60%] items-end relative group",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
received: "self-start",
|
||||||
|
sent: "self-end flex-row-reverse",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
default: "",
|
||||||
|
ai: "max-w-full w-full items-center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "received",
|
||||||
|
layout: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ChatBubbleProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof chatBubbleVariant> {}
|
||||||
|
|
||||||
|
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||||
|
({ className, variant, layout, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
chatBubbleVariant({ variant, layout, className }),
|
||||||
|
"relative group",
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child) =>
|
||||||
|
React.isValidElement(child) && typeof child.type !== "string"
|
||||||
|
? React.cloneElement(child, {
|
||||||
|
variant,
|
||||||
|
layout,
|
||||||
|
} as React.ComponentProps<typeof child.type>)
|
||||||
|
: child,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ChatBubble.displayName = "ChatBubble";
|
||||||
|
|
||||||
|
// ChatBubbleAvatar
|
||||||
|
interface ChatBubbleAvatarProps {
|
||||||
|
src?: string;
|
||||||
|
fallback?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleAvatar: React.FC<ChatBubbleAvatarProps> = ({
|
||||||
|
src,
|
||||||
|
fallback,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<Avatar className={className}>
|
||||||
|
<AvatarImage src={src} alt="Avatar" />
|
||||||
|
<AvatarFallback>{fallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ChatBubbleMessage
|
||||||
|
const chatBubbleMessageVariants = cva("p-4", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
received:
|
||||||
|
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
|
||||||
|
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
default: "",
|
||||||
|
ai: "border-t w-full rounded-none bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "received",
|
||||||
|
layout: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ChatBubbleMessageProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof chatBubbleMessageVariants> {
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleMessage = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChatBubbleMessageProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, variant, layout, isLoading = false, children, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
chatBubbleMessageVariants({ variant, layout, className }),
|
||||||
|
"break-words max-w-full whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<MessageLoading />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ChatBubbleMessage.displayName = "ChatBubbleMessage";
|
||||||
|
|
||||||
|
// ChatBubbleTimestamp
|
||||||
|
interface ChatBubbleTimestampProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
|
||||||
|
timestamp,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div className={cn("text-xs mt-2 text-right", className)} {...props}>
|
||||||
|
{timestamp}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ChatBubbleAction
|
||||||
|
type ChatBubbleActionProps = ButtonProps & {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
variant = "ghost",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ChatBubbleActionWrapperProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: "sent" | "received";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleActionWrapper = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChatBubbleActionWrapperProps
|
||||||
|
>(({ variant, className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 -translate-y-1/2 flex opacity-0 group-hover:opacity-100 transition-opacity duration-200",
|
||||||
|
variant === "sent"
|
||||||
|
? "-left-1 -translate-x-full flex-row-reverse"
|
||||||
|
: "-right-1 translate-x-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChatBubble,
|
||||||
|
ChatBubbleAvatar,
|
||||||
|
ChatBubbleMessage,
|
||||||
|
ChatBubbleTimestamp,
|
||||||
|
chatBubbleVariant,
|
||||||
|
chatBubbleMessageVariants,
|
||||||
|
ChatBubbleAction,
|
||||||
|
ChatBubbleActionWrapper,
|
||||||
|
};
|
23
src/components/ui/chat/chat-input.tsx
Normal file
23
src/components/ui/chat/chat-input.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatInputProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement>{}
|
||||||
|
|
||||||
|
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Textarea
|
||||||
|
autoComplete="off"
|
||||||
|
ref={ref}
|
||||||
|
name="message"
|
||||||
|
className={cn(
|
||||||
|
"max-h-12 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center h-16 resize-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ChatInput.displayName = "ChatInput";
|
||||||
|
|
||||||
|
export { ChatInput };
|
55
src/components/ui/chat/chat-message-list.tsx
Normal file
55
src/components/ui/chat/chat-message-list.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
|
||||||
|
|
||||||
|
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
smooth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
|
||||||
|
({ className, children, smooth = false, ...props }, _ref) => {
|
||||||
|
const {
|
||||||
|
scrollRef,
|
||||||
|
isAtBottom,
|
||||||
|
autoScrollEnabled,
|
||||||
|
scrollToBottom,
|
||||||
|
disableAutoScroll,
|
||||||
|
} = useAutoScroll({
|
||||||
|
smooth,
|
||||||
|
content: children,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<div
|
||||||
|
className={`flex flex-col w-full h-full p-4 overflow-y-auto ${className}`}
|
||||||
|
ref={scrollRef}
|
||||||
|
onWheel={disableAutoScroll}
|
||||||
|
onTouchMove={disableAutoScroll}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAtBottom && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="absolute bottom-2 left-1/2 transform -translate-x-1/2 inline-flex rounded-full shadow-md"
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatMessageList.displayName = "ChatMessageList";
|
||||||
|
|
||||||
|
export { ChatMessageList };
|
153
src/components/ui/chat/expandable-chat.tsx
Normal file
153
src/components/ui/chat/expandable-chat.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { X, MessageCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type ChatPosition = "bottom-right" | "bottom-left";
|
||||||
|
export type ChatSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||||
|
|
||||||
|
const chatConfig = {
|
||||||
|
dimensions: {
|
||||||
|
sm: "sm:max-w-sm sm:max-h-[500px]",
|
||||||
|
md: "sm:max-w-md sm:max-h-[600px]",
|
||||||
|
lg: "sm:max-w-lg sm:max-h-[700px]",
|
||||||
|
xl: "sm:max-w-xl sm:max-h-[800px]",
|
||||||
|
full: "sm:w-full sm:h-full",
|
||||||
|
},
|
||||||
|
positions: {
|
||||||
|
"bottom-right": "bottom-5 right-5",
|
||||||
|
"bottom-left": "bottom-5 left-5",
|
||||||
|
},
|
||||||
|
chatPositions: {
|
||||||
|
"bottom-right": "sm:bottom-[calc(100%+10px)] sm:right-0",
|
||||||
|
"bottom-left": "sm:bottom-[calc(100%+10px)] sm:left-0",
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
open: "pointer-events-auto opacity-100 visible scale-100 translate-y-0",
|
||||||
|
closed:
|
||||||
|
"pointer-events-none opacity-0 invisible scale-100 sm:translate-y-5",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ExpandableChatProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
position?: ChatPosition;
|
||||||
|
size?: ChatSize;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpandableChat: React.FC<ExpandableChatProps> = ({
|
||||||
|
className,
|
||||||
|
position = "bottom-right",
|
||||||
|
size = "md",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const toggleChat = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(`fixed ${chatConfig.positions[position]} z-50`, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={chatRef}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col bg-background border sm:rounded-lg shadow-md overflow-hidden transition-all duration-250 ease-out sm:absolute sm:w-[90vw] sm:h-[80vh] fixed inset-0 w-full h-full sm:inset-auto",
|
||||||
|
chatConfig.chatPositions[position],
|
||||||
|
chatConfig.dimensions[size],
|
||||||
|
isOpen ? chatConfig.states.open : chatConfig.states.closed,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 sm:hidden"
|
||||||
|
onClick={toggleChat}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ExpandableChatToggle
|
||||||
|
icon={icon}
|
||||||
|
isOpen={isOpen}
|
||||||
|
toggleChat={toggleChat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ExpandableChat.displayName = "ExpandableChat";
|
||||||
|
|
||||||
|
const ExpandableChatHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center justify-between p-4 border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
ExpandableChatHeader.displayName = "ExpandableChatHeader";
|
||||||
|
|
||||||
|
const ExpandableChatBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => <div className={cn("flex-grow overflow-y-auto", className)} {...props} />;
|
||||||
|
|
||||||
|
ExpandableChatBody.displayName = "ExpandableChatBody";
|
||||||
|
|
||||||
|
const ExpandableChatFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => <div className={cn("border-t p-4", className)} {...props} />;
|
||||||
|
|
||||||
|
ExpandableChatFooter.displayName = "ExpandableChatFooter";
|
||||||
|
|
||||||
|
interface ExpandableChatToggleProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggleChat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpandableChatToggle: React.FC<ExpandableChatToggleProps> = ({
|
||||||
|
className,
|
||||||
|
icon,
|
||||||
|
isOpen,
|
||||||
|
toggleChat,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={toggleChat}
|
||||||
|
className={cn(
|
||||||
|
"w-14 h-14 rounded-full shadow-md flex items-center justify-center hover:shadow-lg hover:shadow-black/30 transition-all duration-300",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
icon || <MessageCircle className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
ExpandableChatToggle.displayName = "ExpandableChatToggle";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ExpandableChat,
|
||||||
|
ExpandableChatHeader,
|
||||||
|
ExpandableChatBody,
|
||||||
|
ExpandableChatFooter,
|
||||||
|
};
|
135
src/components/ui/chat/hooks/useAutoScroll.tsx
Normal file
135
src/components/ui/chat/hooks/useAutoScroll.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// @hidden
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface ScrollState {
|
||||||
|
isAtBottom: boolean;
|
||||||
|
autoScrollEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAutoScrollOptions {
|
||||||
|
offset?: number;
|
||||||
|
smooth?: boolean;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoScroll(options: UseAutoScrollOptions = {}) {
|
||||||
|
const { offset = 20, smooth = false, content } = options;
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastContentHeight = useRef(0);
|
||||||
|
const userHasScrolled = useRef(false);
|
||||||
|
|
||||||
|
const [scrollState, setScrollState] = useState<ScrollState>({
|
||||||
|
isAtBottom: true,
|
||||||
|
autoScrollEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkIsAtBottom = useCallback(
|
||||||
|
(element: HTMLElement) => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
const distanceToBottom = Math.abs(
|
||||||
|
scrollHeight - scrollTop - clientHeight
|
||||||
|
);
|
||||||
|
return distanceToBottom <= offset;
|
||||||
|
},
|
||||||
|
[offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(
|
||||||
|
(instant?: boolean) => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const targetScrollTop =
|
||||||
|
scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
|
||||||
|
|
||||||
|
if (instant) {
|
||||||
|
scrollRef.current.scrollTop = targetScrollTop;
|
||||||
|
} else {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
top: targetScrollTop,
|
||||||
|
behavior: smooth ? "smooth" : "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollState({
|
||||||
|
isAtBottom: true,
|
||||||
|
autoScrollEnabled: true,
|
||||||
|
});
|
||||||
|
userHasScrolled.current = false;
|
||||||
|
},
|
||||||
|
[smooth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const atBottom = checkIsAtBottom(scrollRef.current);
|
||||||
|
|
||||||
|
setScrollState((prev) => ({
|
||||||
|
isAtBottom: atBottom,
|
||||||
|
// Re-enable auto-scroll if at the bottom
|
||||||
|
autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
|
||||||
|
}));
|
||||||
|
}, [checkIsAtBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
element.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => element.removeEventListener("scroll", handleScroll);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = scrollRef.current;
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
const currentHeight = scrollElement.scrollHeight;
|
||||||
|
const hasNewContent = currentHeight !== lastContentHeight.current;
|
||||||
|
|
||||||
|
if (hasNewContent) {
|
||||||
|
if (scrollState.autoScrollEnabled) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottom(lastContentHeight.current === 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastContentHeight.current = currentHeight;
|
||||||
|
}
|
||||||
|
}, [content, scrollState.autoScrollEnabled, scrollToBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (scrollState.autoScrollEnabled) {
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [scrollState.autoScrollEnabled, scrollToBottom]);
|
||||||
|
|
||||||
|
const disableAutoScroll = useCallback(() => {
|
||||||
|
const atBottom = scrollRef.current
|
||||||
|
? checkIsAtBottom(scrollRef.current)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Only disable if not at bottom
|
||||||
|
if (!atBottom) {
|
||||||
|
userHasScrolled.current = true;
|
||||||
|
setScrollState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
autoScrollEnabled: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [checkIsAtBottom]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollRef,
|
||||||
|
isAtBottom: scrollState.isAtBottom,
|
||||||
|
autoScrollEnabled: scrollState.autoScrollEnabled,
|
||||||
|
scrollToBottom: () => scrollToBottom(false),
|
||||||
|
disableAutoScroll,
|
||||||
|
};
|
||||||
|
}
|
45
src/components/ui/chat/message-loading.tsx
Normal file
45
src/components/ui/chat/message-loading.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// @hidden
|
||||||
|
export default function MessageLoading() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
<circle cx="4" cy="12" r="2" fill="currentColor">
|
||||||
|
<animate
|
||||||
|
id="spinner_qFRN"
|
||||||
|
begin="0;spinner_OcgL.end+0.25s"
|
||||||
|
attributeName="cy"
|
||||||
|
calcMode="spline"
|
||||||
|
dur="0.6s"
|
||||||
|
values="12;6;12"
|
||||||
|
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="12" cy="12" r="2" fill="currentColor">
|
||||||
|
<animate
|
||||||
|
begin="spinner_qFRN.begin+0.1s"
|
||||||
|
attributeName="cy"
|
||||||
|
calcMode="spline"
|
||||||
|
dur="0.6s"
|
||||||
|
values="12;6;12"
|
||||||
|
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="20" cy="12" r="2" fill="currentColor">
|
||||||
|
<animate
|
||||||
|
id="spinner_OcgL"
|
||||||
|
begin="spinner_qFRN.begin+0.2s"
|
||||||
|
attributeName="cy"
|
||||||
|
calcMode="spline"
|
||||||
|
dur="0.6s"
|
||||||
|
values="12;6;12"
|
||||||
|
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user