From 74b4317578669994cba5399cafc59125b3b5a3ee Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Jul 2026 10:09:17 +0800 Subject: [PATCH] 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. --- .../_components/detail.tsx | 377 +++++++----------- .../conversations/_components/chat-panel.tsx | 206 +++------- .../chat/conversation-message-bubble.tsx | 53 +++ .../chat/conversation-message-row.tsx | 104 +++++ .../chat/conversation-message-scroller.tsx | 151 +++++++ web/components/support-chat/message-list.tsx | 257 ++++-------- web/components/ui/message-scroller.tsx | 131 ++++++ web/components/ui/message.tsx | 92 +++++ web/package.json | 1 + web/pnpm-lock.yaml | 19 + 10 files changed, 839 insertions(+), 552 deletions(-) create mode 100644 web/components/chat/conversation-message-bubble.tsx create mode 100644 web/components/chat/conversation-message-row.tsx create mode 100644 web/components/chat/conversation-message-scroller.tsx create mode 100644 web/components/ui/message-scroller.tsx create mode 100644 web/components/ui/message.tsx diff --git a/web/app/dashboard/conversation-monitor/_components/detail.tsx b/web/app/dashboard/conversation-monitor/_components/detail.tsx index a25e81c..6782109 100644 --- a/web/app/dashboard/conversation-monitor/_components/detail.tsx +++ b/web/app/dashboard/conversation-monitor/_components/detail.tsx @@ -2,13 +2,16 @@ import { CheckCheckIcon, EyeIcon, MessageCircleMoreIcon } from "lucide-react"; import Image from "next/image"; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, -} from "react"; +import { useEffect } from "react"; +import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"; +import { + ConversationMessageRow, +} from "@/components/chat/conversation-message-row"; +import { + ConversationMessageScroller, + ConversationMessageScrollerItem, +} from "@/components/chat/conversation-message-scroller"; import { ImMessageHTML } from "@/components/im-message-html"; import { useImageLightbox } from "@/components/image-lightbox"; import { ProjectDialog } from "@/components/project-dialog"; @@ -114,51 +117,41 @@ function getParticipantIdentity( return participant.participantId || participant.externalParticipantId || "-"; } -function getMessageLayout(message: AdminMessage) { - if (message.senderType === "customer") { - return { - rowClassName: "justify-start", - bubbleClassName: "border-border/70 bg-muted/60 text-foreground shadow-sm", - htmlClassName: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md", - recalledBubbleClassName: - "border-dashed border-border/70 bg-muted/40 text-muted-foreground", - recalledHtmlClassName: "text-muted-foreground [&_p]:text-muted-foreground", - metaClassName: "text-left", - }; +function getMessageAlign(message: AdminMessage): "start" | "end" { + return message.senderType === "customer" || message.senderType === "system" + ? "start" + : "end"; +} + +function getMessageVariant(message: AdminMessage) { + switch (message.senderType) { + case "agent": + return "agent" as const; + case "ai": + return "ai" as const; + case "system": + return "system" as const; + default: + return "customer" as const; } - if (message.senderType === "system") { - return { - rowClassName: "justify-center", - bubbleClassName: - "border-dashed border-border bg-muted/60 text-muted-foreground", - htmlClassName: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md", - recalledBubbleClassName: - "border-dashed border-border/70 bg-muted/40 text-muted-foreground", - recalledHtmlClassName: "text-muted-foreground [&_p]:text-muted-foreground", - metaClassName: "text-center", - }; +} + +function getMessageHtmlClassName(message: AdminMessage, isRecalled: boolean) { + if (isRecalled) { + return message.senderType === "agent" || message.senderType === "ai" + ? "text-emerald-800 [&_p]:text-emerald-800" + : "text-muted-foreground [&_p]:text-muted-foreground"; } - if (message.senderType === "ai") { - return { - rowClassName: "justify-end", - bubbleClassName: "border-primary/15 bg-primary/5 text-foreground shadow-sm", - htmlClassName: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md", - recalledBubbleClassName: - "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800", - recalledHtmlClassName: "text-emerald-800 [&_p]:text-emerald-800", - metaClassName: "text-right", - }; + if (message.senderType === "agent") { + return "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md"; } - return { - rowClassName: "justify-end", - bubbleClassName: "border-transparent bg-emerald-600 text-white shadow-sm", - htmlClassName: - "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md", - recalledBubbleClassName: - "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800", - recalledHtmlClassName: "text-emerald-800 [&_p]:text-emerald-800", - metaClassName: "text-right", - }; + return "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md"; +} + +function getRecalledBubbleClassName(message: AdminMessage) { + return message.senderType === "agent" || message.senderType === "ai" + ? "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800" + : "border-dashed border-border/70 bg-muted/40 text-muted-foreground"; } export function ConversationDetailDialog({ @@ -187,91 +180,14 @@ export function ConversationDetailDialog({ const statusMeta = currentConversation ? getStatusMeta(currentConversation.status, t) : null; - const messageBottomRef = useRef(null); - const messagesScrollRootRef = useRef(null); - const loadMoreSentinelRef = useRef(null); - const pendingScrollAnchorRef = useRef<{ - scrollHeight: number; - scrollTop: number; - } | null>(null); - const prevLoadingMoreRef = useRef(false); const { open: openImageLightbox, close: closeImageLightbox } = useImageLightbox(); - const getMessagesViewport = useCallback((): HTMLElement | null => { - return ( - messagesScrollRootRef.current?.querySelector( - '[data-slot="scroll-area-viewport"]', - ) ?? null - ); - }, []); - useEffect(() => { if (!open) { closeImageLightbox(); - return; } - if (loading) { - return; - } - const bottom = messageBottomRef.current; - if (!bottom) { - return; - } - bottom.scrollIntoView({ block: "end", behavior: "smooth" }); - }, [open, loading, closeImageLightbox]); - - useLayoutEffect(() => { - const wasLoading = prevLoadingMoreRef.current; - prevLoadingMoreRef.current = loadingMoreMessages; - if (wasLoading && !loadingMoreMessages && pendingScrollAnchorRef.current) { - const vp = getMessagesViewport(); - const anchor = pendingScrollAnchorRef.current; - pendingScrollAnchorRef.current = null; - if (vp && anchor) { - const delta = vp.scrollHeight - anchor.scrollHeight; - vp.scrollTop = anchor.scrollTop + delta; - } - } - }, [loadingMoreMessages, messages, getMessagesViewport]); - - useEffect(() => { - if (!open || loading || !messagesHasMore || !onLoadMoreMessages) { - return; - } - const root = getMessagesViewport(); - const sentinel = loadMoreSentinelRef.current; - if (!root || !sentinel) { - return; - } - const observer = new IntersectionObserver( - (entries) => { - const hit = entries.some((e) => e.isIntersecting); - if (!hit || loadingMoreMessages) { - return; - } - const vp = getMessagesViewport(); - if (vp) { - pendingScrollAnchorRef.current = { - scrollHeight: vp.scrollHeight, - scrollTop: vp.scrollTop, - }; - } - void onLoadMoreMessages(); - }, - { root, rootMargin: "120px 0px 0px 0px", threshold: 0 }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, [ - open, - loading, - messagesHasMore, - loadingMoreMessages, - messages.length, - onLoadMoreMessages, - getMessagesViewport, - ]); + }, [open, closeImageLightbox]); return ( ) : currentConversation ? ( -
-