chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
"use client"
import { memo } from "react"
import { BotIcon, Loader2Icon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
import { useI18n } from "@/i18n/provider"
import { getAgentActivityLabel } from "@/lib/agent-activity-label"
import { cn } from "@/lib/utils"
export type AgentActivityBubbleProps = {
stepCode: string
toolName?: string
className?: string
isCustomer?: boolean
}
export const AgentActivityBubble = memo(function AgentActivityBubble({
stepCode,
toolName,
className,
isCustomer = true,
}: AgentActivityBubbleProps) {
const t = useI18n()
const label = getAgentActivityLabel({ stepCode, toolName, isCustomer }, t)
return (
<ConversationMessageRow
align="start"
avatar={
<Avatar className="size-8">
<AvatarFallback className="bg-primary/10 text-primary">
<BotIcon aria-hidden="true" className="size-4" />
</AvatarFallback>
</Avatar>
}
contentClassName="max-w-[86%] gap-1.5"
className={cn("my-1 animate-in fade-in-50 duration-200", className)}
>
<ConversationMessageBubble
variant="system"
role="status"
aria-live="polite"
className="flex items-center gap-2 rounded-lg border border-solid border-border/60 bg-muted/50 px-3 py-2 text-xs text-muted-foreground shadow-sm"
>
<Loader2Icon aria-hidden="true" className="size-3.5 shrink-0 animate-spin text-primary" />
<span className="font-medium text-foreground/80">{label}</span>
</ConversationMessageBubble>
</ConversationMessageRow>
)
})

View File

@@ -0,0 +1,64 @@
"use client"
import type { ReactNode } from "react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { cn } from "@/lib/utils"
export type ConversationMessageVariant =
| "customer"
| "agent"
| "ai"
| "system"
| "recalled"
type ConversationMessageBubbleProps = React.ComponentProps<"div"> & {
variant: ConversationMessageVariant
children: ReactNode
className?: string
}
function getBubbleVariant(variant: ConversationMessageVariant) {
switch (variant) {
case "customer":
return "secondary" as const
case "system":
return "muted" as const
case "ai":
return "tinted" as const
case "agent":
return "default" as const
case "recalled":
return "outline" as const
default:
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
}
}
export function ConversationMessageBubble({
variant,
children,
className,
...props
}: ConversationMessageBubbleProps) {
return (
<Bubble variant={getBubbleVariant(variant)} {...props}>
<BubbleContent className={cn("rounded-2xl px-4 py-3 leading-6", getContentClassName(variant), className)}>
{children}
</BubbleContent>
</Bubble>
)
}

View File

@@ -0,0 +1,109 @@
"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(
"self-start group-has-data-[slot=message-footer]/message:translate-y-0 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>
)
}

View File

@@ -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 }

View File

@@ -0,0 +1,729 @@
"use client"
import { useEffect, useRef, useState, type ChangeEvent } from "react"
import Placeholder from "@tiptap/extension-placeholder"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import {
ImageIcon,
Loader2Icon,
MessageSquareTextIcon,
PaperclipIcon,
SendHorizonalIcon,
SendIcon,
SparklesIcon,
XIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
buildSendableEditorHTML,
hasUploadingEditorImages,
markEditorImageUploadedByTitle,
MessageImageExtension,
removeEditorImageByTitle,
revokeEditorObjectUrl,
revokeEditorObjectUrls,
setEditorImageUploadingByTitle,
type UploadedEditorImage,
} from "@/lib/im-editor-image"
import { generateUUID } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
export type UploadedMessageEditorImage = UploadedEditorImage & {
url: string
}
export type MessageEditorQuickReply = {
id: number
groupName?: string
title: string
content: string
}
type SharedMessageEditorVariant = "customer" | "agent"
export type SharedMessageEditorAICopilot = {
onSuggest: () => Promise<string | null>
loading?: boolean
}
export type SharedMessageEditorProps = {
variant: SharedMessageEditorVariant
conversationId?: number | null
disabled?: boolean
uploadingAsset?: boolean
manageLocalUploading?: boolean
quickReplies?: {
open: boolean
loading: boolean
items: MessageEditorQuickReply[]
onOpenChange: (open: boolean) => void
}
aiCopilot?: SharedMessageEditorAICopilot
onSend: (html: string) => Promise<void>
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
onSendAttachment: (file: File) => Promise<void>
onTyping?: () => void
}
export function SharedMessageEditor({
variant,
conversationId,
disabled = false,
uploadingAsset = false,
manageLocalUploading = false,
quickReplies,
aiCopilot,
onSend,
onUploadImage,
onSendAttachment,
onTyping,
}: SharedMessageEditorProps) {
const t = useI18n()
const [localUploading, setLocalUploading] = useState(false)
const [quickReplySearch, setQuickReplySearch] = useState("")
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
const quickReplySearchRef = useRef(quickReplySearch)
quickReplySearchRef.current = quickReplySearch
const isSlashTriggeredRef = useRef(false)
const quickRepliesRef = useRef(quickReplies)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
const onSendRef = useRef(onSend)
const onUploadImageRef = useRef(onUploadImage)
const onSendAttachmentRef = useRef(onSendAttachment)
const onTypingRef = useRef(onTyping)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
placeholderRef.current = t("conversation.editorPlaceholder")
useEffect(() => {
quickRepliesRef.current = quickReplies
}, [quickReplies])
useEffect(() => {
const objectUrls = objectUrlsRef.current
return () => {
revokeEditorObjectUrls(objectUrls)
}
}, [])
useEffect(() => {
onSendRef.current = onSend
}, [onSend])
useEffect(() => {
onUploadImageRef.current = onUploadImage
}, [onUploadImage])
useEffect(() => {
onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
onTypingRef.current = onTyping
}, [onTyping])
useEffect(() => {
setSuggestedReply(null)
}, [conversationId])
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
horizontalRule: false,
}),
MessageImageExtension,
Placeholder.configure({
placeholder: () => placeholderRef.current,
}),
],
content: "",
onUpdate: ({ editor }) => {
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
onTypingRef.current?.()
}
if (!quickRepliesRef.current || quickRepliesRef.current.loading) {
return
}
const { $from } = editor.state.selection
const blockText = $from.parent.textContent
const trimmed = blockText.trimStart()
const match = trimmed.match(/^\/([^\s]*)$/)
if (match) {
isSlashTriggeredRef.current = true
const query = match[1]
setQuickReplySearch(query)
if (!quickRepliesRef.current.open) {
quickRepliesRef.current.onOpenChange(true)
}
} else if (isSlashTriggeredRef.current && quickRepliesRef.current.open) {
isSlashTriggeredRef.current = false
setQuickReplySearch("")
quickRepliesRef.current.onOpenChange(false)
}
},
editorProps: {
attributes: {
class: getEditorClassName(variant),
},
handleKeyDown: (_view, event) => {
if (event.key === "Escape" && quickRepliesRef.current?.open) {
event.preventDefault()
quickRepliesRef.current.onOpenChange(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
return true
}
if (
(event.key === "Enter" || event.key === "Tab") &&
!event.shiftKey &&
quickRepliesRef.current?.open
) {
const query = quickReplySearchRef.current.toLowerCase().trim()
const items = quickRepliesRef.current.items
const matching = items.filter((item) => {
if (!query) return true
const target = `${item.groupName ?? ""} ${item.title} ${item.content}`.toLowerCase()
return target.includes(query)
})
if (matching.length > 0) {
event.preventDefault()
handleInsertQuickReply(matching[0])
return true
}
if (event.key === "Enter") {
event.preventDefault()
return true
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
void handleSend()
return true
}
return false
},
handlePaste: (_view, event) => {
if (disabled || isUploading) {
return false
}
const imageFile = getClipboardImageFile(event.clipboardData)
if (!imageFile) {
return false
}
event.preventDefault()
void insertUploadedImage(imageFile)
return true
},
},
})
useEffect(() => {
if (!editor) {
return
}
editor.setEditable(!disabled && !isUploading)
}, [disabled, editor, isUploading])
useEffect(() => {
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
return
}
requestAnimationFrame(() => {
editor.commands.focus()
})
}, [disabled, editor, isUploading])
async function handleSend() {
if (!editor || disabled || isUploading) {
return
}
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
return
}
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
setQuickReplySearch("")
isSlashTriggeredRef.current = false
quickRepliesRef.current?.onOpenChange(false)
setSuggestedReply(null)
if (!isCustomer) {
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
}
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || disabled || isUploading) {
restoreFocusIfNeeded()
return
}
await insertUploadedImage(file)
}
async function insertUploadedImage(file: File) {
if (!editor || disabled || isUploading) {
return
}
shouldRestoreFocusRef.current = true
const objectUrl = URL.createObjectURL(file)
objectUrlsRef.current.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
.focus()
.setImage({
src: objectUrl,
alt: file.name || "uploading-image",
title: placeholderId,
})
.run()
setEditorImageUploadingByTitle(editor, placeholderId)
try {
setLocalUploading(true)
const uploaded = await onUploadImageRef.current(file)
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId)
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return
}
markEditorImageUploadedByTitle(
editor,
placeholderId,
uploaded,
uploadedImagesRef.current
)
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
}
}
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || disabled || isUploading) {
restoreFocusIfNeeded()
return
}
shouldRestoreFocusRef.current = editor?.isFocused ?? true
try {
setLocalUploading(true)
await onSendAttachmentRef.current(file)
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
if (editor && !disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
}
})
}
}
function handleInsertQuickReply(item: MessageEditorQuickReply) {
if (!editor || disabled || isUploading) {
return
}
if (!item.content.trim()) {
return
}
const { $from } = editor.state.selection
const blockText = $from.parent.textContent
const trimmed = blockText.trimStart()
const match = trimmed.match(/^\/([^\s]*)$/)
if (match) {
const slashIndex = blockText.indexOf("/")
const blockStart = $from.start() + slashIndex
const matchLen = match[0].length
editor
.chain()
.focus()
.deleteRange({ from: blockStart, to: blockStart + matchLen })
.insertContent(item.content)
.run()
} else {
const fullText = editor.getText().trim()
const fullMatch = fullText.match(/^\/([^\s]*)$/)
if (fullMatch) {
editor.chain().focus().setContent(item.content).run()
} else {
editor.chain().focus().insertContent(item.content).run()
}
}
quickRepliesRef.current?.onOpenChange(false)
setQuickReplySearch("")
isSlashTriggeredRef.current = false
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
function restoreFocusIfNeeded() {
if (editor && shouldRestoreFocusRef.current) {
requestAnimationFrame(() => {
editor.commands.focus()
})
}
}
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
async function handleTriggerSuggest() {
if (!aiCopilot || disabled || isUploading || isGenerating) {
return
}
setIsGeneratingSuggestion(true)
try {
const text = await aiCopilot.onSuggest()
if (text && text.trim()) {
setSuggestedReply(text.trim())
}
} finally {
setIsGeneratingSuggestion(false)
}
}
function handleInsertSuggestedReply() {
if (!editor || !suggestedReply) {
return
}
editor.chain().focus().insertContent(suggestedReply).run()
setSuggestedReply(null)
requestAnimationFrame(() => {
editor.commands.focus("end")
})
}
function handleDismissSuggestedReply() {
setSuggestedReply(null)
}
const editorContent = (
<>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleSelectImage}
/>
<input
ref={attachmentInputRef}
type="file"
className="hidden"
onChange={handleSelectAttachment}
/>
{!isCustomer && suggestedReply ? (
<div className="m-2 rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="flex items-center justify-between gap-2 pb-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-primary">
<SparklesIcon className="size-3.5" />
<span>{t("aiCopilot.suggestReply")}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-foreground"
onClick={handleDismissSuggestedReply}
aria-label={t("aiCopilot.discardReply")}
title={t("aiCopilot.discardReply")}
>
<XIcon className="size-3.5" />
</Button>
</div>
<div className="line-clamp-3 select-text text-xs leading-5 text-foreground/90 whitespace-pre-wrap">
{suggestedReply}
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleDismissSuggestedReply}
>
{t("aiCopilot.discardReply")}
</Button>
<Button
type="button"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={handleInsertSuggestedReply}
>
{t("aiCopilot.insertReply")}
</Button>
</div>
</div>
) : null}
{isCustomer ? (
<div className="min-h-10">
<EditorContent editor={editor} />
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden px-2 py-1">
<EditorContent editor={editor} className="h-full" />
</div>
)}
<div className={getToolbarClassName(variant)}>
<div className={isCustomer ? "flex items-center gap-1.5" : "flex items-center gap-1"}>
<Button
type="button"
variant="ghost"
size="icon"
className={getIconButtonClassName(variant)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
shouldRestoreFocusRef.current = editor?.isFocused ?? true
imageInputRef.current?.click()
}}
disabled={disabled || isUploading}
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
>
<ImageIcon className={isCustomer ? undefined : "size-4"} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={getIconButtonClassName(variant)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
shouldRestoreFocusRef.current = editor?.isFocused ?? true
attachmentInputRef.current?.click()
}}
disabled={disabled || isUploading}
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
>
<PaperclipIcon className={isCustomer ? undefined : "size-4"} />
</Button>
{quickReplies ? (
<Popover
open={quickReplies.open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setQuickReplySearch("")
isSlashTriggeredRef.current = false
}
quickReplies.onOpenChange(nextOpen)
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={disabled || isUploading || quickReplies.loading}
onMouseDown={(event) => event.preventDefault()}
title={t("conversation.quickReplySlashHint")}
aria-label={t("conversation.quickReplySlashHint")}
/>
}
>
<MessageSquareTextIcon className="size-4" />
</PopoverTrigger>
<PopoverContent
className="w-[30rem] p-0"
align="start"
initialFocus={!isSlashTriggeredRef.current}
>
<Command>
<CommandInput
value={quickReplySearch}
onValueChange={setQuickReplySearch}
placeholder={t("conversation.searchQuickReplies")}
/>
<CommandList>
<CommandEmpty>{t("conversation.emptyQuickReplies")}</CommandEmpty>
<CommandGroup>
{quickReplies.items.map((item) => (
<CommandItem
key={item.id}
value={`${item.groupName ?? ""} ${item.title} ${item.content}`}
onSelect={() => handleInsertQuickReply(item)}
>
<div className="flex min-w-0 flex-col gap-0.5 py-0.5">
<span className="line-clamp-1 text-sm">
{item.groupName
? `${item.groupName} / ${item.title}`
: item.title}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">
{item.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : null}
{!isCustomer && aiCopilot ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-primary hover:bg-primary/10"
disabled={disabled || isUploading || isGenerating || aiCopilot.loading}
onClick={handleTriggerSuggest}
title={isGenerating ? t("aiCopilot.generating") : t("aiCopilot.suggestReply")}
aria-label={t("aiCopilot.suggestReply")}
>
{isGenerating ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
</Button>
) : null}
{!isCustomer && quickReplies ? (
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline-block pl-1">
{t("conversation.quickReplySlashHint")}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<p className={isCustomer ? "hidden text-[10px] text-muted-foreground sm:block" : "text-xs text-muted-foreground"}>
{t("conversation.enterToSend")}
</p>
{isCustomer ? (
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
aria-label={t("conversation.send")}
title={t("conversation.send")}
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
>
<SendHorizonalIcon />
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => void handleSend()}
disabled={disabled || isUploading}
>
<SendIcon className="mr-1 size-4" />
{isUploading ? t("conversation.uploading") : t("conversation.send")}
</Button>
)}
</div>
</div>
</>
)
if (isCustomer) {
return (
<div className="px-3 pt-2 pb-3">
<div className="rounded-xl border border-border bg-background p-2 shadow-[0_8px_24px_rgba(15,23,42,0.05)] dark:shadow-none">
{editorContent}
</div>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col p-2">
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border bg-card">
{editorContent}
</div>
</div>
)
}
function getEditorClassName(variant: SharedMessageEditorVariant) {
if (variant === "customer") {
return "agent-desk-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-lg [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55"
}
return "h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.agent-desk-editor-image-wrap]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-md [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
}
function getToolbarClassName(variant: SharedMessageEditorVariant) {
if (variant === "customer") {
return "mt-2 flex items-center justify-between"
}
return "flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2"
}
function getIconButtonClassName(variant: SharedMessageEditorVariant) {
if (variant === "customer") {
return "text-muted-foreground hover:bg-muted hover:text-foreground"
}
return "size-8"
}
function isMeaningfulHTML(html: string) {
const normalized = html
.replace(/<p><\/p>/g, "")
.replace(/<p><br><\/p>/g, "")
.replace(/\s+/g, "")
if (/<img[\s\S]*?>/i.test(normalized)) {
return true
}
const plainText = normalized.replace(/<[^>]+>/g, "").trim()
return plainText !== ""
}
function getClipboardImageFile(data: DataTransfer | null) {
if (!data) {
return null
}
for (const item of Array.from(data.items)) {
if (item.kind === "file" && item.type.startsWith("image/")) {
return item.getAsFile()
}
}
return null
}