feat: refactor chat panel and message components for improved scrolling and message display

- Replaced manual scroll handling in ChatPanel with ConversationMessageScroller for better performance and maintainability.
- Introduced ConversationMessageBubble and ConversationMessageRow components for consistent message styling.
- Updated SupportChatMessageList to utilize new message components and scrolling logic.
- Added utility components for message scroller and message display, enhancing the overall chat experience.
- Updated package dependencies to include @shadcn/react for UI components.
This commit is contained in:
mlogclub
2026-07-29 10:09:17 +08:00
parent 90f5bab456
commit 74b4317578
10 changed files with 839 additions and 552 deletions
@@ -0,0 +1,53 @@
"use client"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
export type ConversationMessageVariant =
| "customer"
| "agent"
| "ai"
| "system"
| "recalled"
type ConversationMessageBubbleProps = {
variant: ConversationMessageVariant
children: ReactNode
className?: string
}
function getBubbleClassName(variant: ConversationMessageVariant) {
switch (variant) {
case "customer":
return "border-border/70 bg-muted/60 text-foreground shadow-sm"
case "system":
return "border-dashed border-border bg-muted/60 text-muted-foreground"
case "ai":
return "border-primary/15 bg-primary/5 text-foreground shadow-sm"
case "agent":
return "border-transparent bg-emerald-600 text-white shadow-sm"
case "recalled":
return "border-dashed border-border/70 bg-muted/40 text-muted-foreground"
default:
return "border-border/70 bg-muted/60 text-foreground shadow-sm"
}
}
export function ConversationMessageBubble({
variant,
children,
className,
}: ConversationMessageBubbleProps) {
return (
<div
className={cn(
"w-fit max-w-full rounded-2xl border px-4 py-3 text-sm leading-6",
getBubbleClassName(variant),
className,
)}
>
{children}
</div>
)
}
@@ -0,0 +1,104 @@
"use client"
import type { ReactNode } from "react"
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message"
import { cn } from "@/lib/utils"
type ConversationMessageRowProps = {
align: "start" | "end"
centered?: boolean
header?: ReactNode
footer?: ReactNode
avatar?: ReactNode
children: ReactNode
className?: string
avatarClassName?: string
contentClassName?: string
headerClassName?: string
footerClassName?: string
}
export function ConversationMessageRow({
align,
centered = false,
header,
footer,
avatar,
children,
className,
avatarClassName,
contentClassName,
headerClassName,
footerClassName,
}: ConversationMessageRowProps) {
if (centered) {
return (
<Message
align="start"
className={cn("justify-center", className)}
>
<MessageContent className={cn("w-fit max-w-[85%] items-center", contentClassName)}>
{header ? (
<MessageHeader className={cn("justify-center text-center", headerClassName)}>
{header}
</MessageHeader>
) : null}
{children}
{footer ? (
<MessageFooter className={cn("justify-center text-center", footerClassName)}>
{footer}
</MessageFooter>
) : null}
</MessageContent>
</Message>
)
}
return (
<Message align={align} className={className}>
{avatar ? (
<MessageAvatar className={cn("bg-transparent", avatarClassName)}>
{avatar}
</MessageAvatar>
) : null}
<MessageContent
className={cn(
"max-w-[85%]",
align === "end" ? "items-end" : "items-start",
contentClassName,
)}
>
{header ? (
<MessageHeader
className={cn(
"gap-2 px-0",
align === "end" ? "justify-end text-right" : "justify-start text-left",
headerClassName,
)}
>
{header}
</MessageHeader>
) : null}
{children}
{footer ? (
<MessageFooter
className={cn(
"gap-2 px-0",
align === "end" ? "justify-end text-right" : "justify-start text-left",
footerClassName,
)}
>
{footer}
</MessageFooter>
) : null}
</MessageContent>
</Message>
)
}
@@ -0,0 +1,151 @@
"use client"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
type ReactNode,
type UIEvent,
} from "react"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
} from "@/components/ui/message-scroller"
import { cn } from "@/lib/utils"
export type ConversationMessageScrollerHandle = {
scrollToBottom: () => void
}
type ConversationMessageScrollerProps = {
children: ReactNode
className?: string
viewportClassName?: string
contentClassName?: string
hasMoreOlder?: boolean
loadingOlder?: boolean
onLoadOlder?: () => void | Promise<void>
onNearBottomChange?: (nearBottom: boolean) => void
onNearBottomVisible?: () => void
topSlot?: ReactNode
scrollThreshold?: number
}
const ConversationMessageScrollerInner = forwardRef<
ConversationMessageScrollerHandle,
ConversationMessageScrollerProps
>(function ConversationMessageScrollerInner(
{
children,
className,
viewportClassName,
contentClassName,
hasMoreOlder = false,
loadingOlder = false,
onLoadOlder,
onNearBottomChange,
onNearBottomVisible,
topSlot,
scrollThreshold = 120,
},
ref,
) {
const { scrollToEnd } = useMessageScroller()
const scrollable = useMessageScrollerScrollable()
const loadingRef = useRef(false)
const nearBottomRef = useRef(false)
const onLoadOlderRef = useRef(onLoadOlder)
const onNearBottomChangeRef = useRef(onNearBottomChange)
const onNearBottomVisibleRef = useRef(onNearBottomVisible)
useEffect(() => {
onLoadOlderRef.current = onLoadOlder
}, [onLoadOlder])
useEffect(() => {
onNearBottomChangeRef.current = onNearBottomChange
}, [onNearBottomChange])
useEffect(() => {
onNearBottomVisibleRef.current = onNearBottomVisible
}, [onNearBottomVisible])
useEffect(() => {
loadingRef.current = loadingOlder
}, [loadingOlder])
useEffect(() => {
const nearBottom = !scrollable.end
nearBottomRef.current = nearBottom
onNearBottomChangeRef.current?.(nearBottom)
if (nearBottom) {
onNearBottomVisibleRef.current?.()
}
}, [scrollable.end])
useImperativeHandle(ref, () => ({
scrollToBottom: () => {
scrollToEnd({ behavior: "auto" })
},
}), [scrollToEnd])
const maybeLoadOlder = useCallback((viewport: HTMLElement) => {
if (!hasMoreOlder || loadingRef.current || !onLoadOlderRef.current) {
return
}
if (viewport.scrollTop > scrollThreshold) {
return
}
loadingRef.current = true
void Promise.resolve(onLoadOlderRef.current()).finally(() => {
loadingRef.current = false
})
}, [hasMoreOlder, scrollThreshold])
const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
maybeLoadOlder(event.currentTarget)
},
[maybeLoadOlder],
)
return (
<MessageScroller className={className}>
<MessageScrollerViewport
className={cn("agent-desk-scrollbar bg-muted/10", viewportClassName)}
onScroll={handleScroll}
preserveScrollOnPrepend
>
<MessageScrollerContent
className={cn("gap-4 px-6 py-5", contentClassName)}
>
{topSlot}
{children}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
)
})
export const ConversationMessageScroller = forwardRef<
ConversationMessageScrollerHandle,
ConversationMessageScrollerProps
>(function ConversationMessageScroller(props, ref) {
return (
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<ConversationMessageScrollerInner {...props} ref={ref} />
</MessageScrollerProvider>
)
})
export { MessageScrollerItem as ConversationMessageScrollerItem }