From f00dc646ace1a90d71ffb3d65fd90bbfdf97108f Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Jul 2026 10:30:50 +0800 Subject: [PATCH] feat: implement ConversationMessageBubble component and refactor message display in ChatPanel --- .../conversations/_components/chat-panel.tsx | 22 ++- .../chat/conversation-message-bubble.tsx | 42 +++--- web/components/ui/bubble.tsx | 128 ++++++++++++++++++ 3 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 web/components/ui/bubble.tsx diff --git a/web/app/dashboard/conversations/_components/chat-panel.tsx b/web/app/dashboard/conversations/_components/chat-panel.tsx index a62a2d3..a5aed36 100644 --- a/web/app/dashboard/conversations/_components/chat-panel.tsx +++ b/web/app/dashboard/conversations/_components/chat-panel.tsx @@ -24,6 +24,7 @@ import { ConversationMessageScrollerItem, type ConversationMessageScrollerHandle, } from "@/components/chat/conversation-message-scroller"; +import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"; import { ImMessageHTML } from "@/components/im-message-html"; import { useImageLightbox } from "@/components/image-lightbox"; import { JsonTreeViewer } from "@/components/json-tree-viewer"; @@ -599,6 +600,13 @@ const MessageItem = memo( ? "[&_p]:text-emerald-800" : "[&_p]:text-muted-foreground"; const showRecallAction = canRecall && !isRecalled; + const bubbleVariant = isRecalled + ? "recalled" + : isAi + ? "ai" + : isAgentSide + ? "agent" + : "customer"; return (
{senderName}
-
@@ -623,7 +632,7 @@ const MessageItem = memo( onImageSettled={onImageSettled} onImageClick={isRecalled ? undefined : openImageLightbox} /> -
+
{formatDateTime(message.sentAt || "")} {isRecalled ? {t("conversation.messageRecalled")} : null} @@ -685,8 +694,9 @@ const MessageItem = memo(
{senderName}
-
@@ -696,7 +706,7 @@ const MessageItem = memo( onImageSettled={onImageSettled} onImageClick={isRecalled ? undefined : openImageLightbox} /> -
+
{formatDateTime(message.sentAt || "")} {isRecalled ? {t("conversation.messageRecalled")} : null} diff --git a/web/components/chat/conversation-message-bubble.tsx b/web/components/chat/conversation-message-bubble.tsx index ca324d0..41525c9 100644 --- a/web/components/chat/conversation-message-bubble.tsx +++ b/web/components/chat/conversation-message-bubble.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react" +import { Bubble, BubbleContent } from "@/components/ui/bubble" import { cn } from "@/lib/utils" export type ConversationMessageVariant = @@ -17,20 +18,33 @@ type ConversationMessageBubbleProps = { className?: string } -function getBubbleClassName(variant: ConversationMessageVariant) { +function getBubbleVariant(variant: ConversationMessageVariant) { switch (variant) { case "customer": - return "border-border/70 bg-muted/60 text-foreground shadow-sm" + return "secondary" as const case "system": - return "border-dashed border-border bg-muted/60 text-muted-foreground" + return "muted" as const case "ai": - return "border-primary/15 bg-primary/5 text-foreground shadow-sm" + return "tinted" as const case "agent": - return "border-transparent bg-emerald-600 text-white shadow-sm" + return "default" as const case "recalled": - return "border-dashed border-border/70 bg-muted/40 text-muted-foreground" + return "outline" as const default: - return "border-border/70 bg-muted/60 text-foreground shadow-sm" + return "secondary" as const + } +} + +function getContentClassName(variant: ConversationMessageVariant) { + switch (variant) { + case "agent": + return "bg-emerald-600 text-white" + case "system": + return "border-dashed text-muted-foreground" + case "recalled": + return "border-dashed bg-muted/40 text-muted-foreground" + default: + return undefined } } @@ -40,14 +54,10 @@ export function ConversationMessageBubble({ className, }: ConversationMessageBubbleProps) { return ( -
- {children} -
+ + + {children} + + ) } diff --git a/web/components/ui/bubble.tsx b/web/components/ui/bubble.tsx new file mode 100644 index 0000000..0b4bfe2 --- /dev/null +++ b/web/components/ui/bubble.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const bubbleVariants = cva( + "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", + { + variants: { + variant: { + default: + "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80", + secondary: + "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", + muted: + "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]", + tinted: + "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]", + outline: + "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30", + ghost: + "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50", + destructive: + "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Bubble({ + variant = "default", + align = "start", + className, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { + align?: "start" | "end" + }) { + return ( +
+ ) +} + +function BubbleContent({ + className, + render, + ...props +}: useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50", + className + ), + }, + props + ), + render, + state: { + slot: "bubble-content", + }, + }) +} + +const bubbleReactionsVariants = cva( + "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0", + { + variants: { + side: { + top: "top-0 -translate-y-3/4", + bottom: "bottom-0 translate-y-3/4", + }, + align: { + start: "left-3", + end: "right-3", + }, + }, + defaultVariants: { + side: "bottom", + align: "end", + }, + } +) + +function BubbleReactions({ + side = "bottom", + align = "end", + className, + ...props +}: React.ComponentProps<"div"> & { + align?: "start" | "end" + side?: "top" | "bottom" +}) { + return ( +
+ ) +} + +export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }