o2o-negosium-original/frontend/src/features/chat/components/ChatMessage.tsx
민헌 630788d9eb fix(chat): 채팅 영역 오른쪽 끝·상단 여백 정렬 보정
- 상품목록 버튼 래퍼에 pr-[16px] 추가 — 채팅 스크롤바 오른쪽 끝과 정렬
- 상단 pt-[64px] 를 스크롤 컨테이너 안쪽으로 이동 — 첫 메시지는 협상절차 카드와 높이 유지, 스크롤 시 여백 없이 맨 위까지 표시
- chat-scroll 스크롤바 트랙에 margin-top 64px — 스크롤바 시작 높이를 협상절차 카드 상단과 일치

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:53:31 +09:00

133 lines
4.8 KiB
TypeScript

import { useEffect, useRef, memo } from 'react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary'
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
export function ChatMessage() {
return (
<div className="flex-1 w-full min-h-0 pr-[16px]">
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pl-[140px] pr-[126px]">
<ChatList />
</div>
</div>
)
}
function ChatList() {
const bottomRef = useRef<HTMLDivElement | null>(null)
const chats = useChatStore((s) => s.messages)
const isLoading = useChatStore((s) => s.isLoading)
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chats, isLoading])
if (!chats || chats.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="body-1 text-neutral-70">채팅 내역이 없습니다.</p>
</div>
)
}
return (
<div className="flex flex-col w-full">
{chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
))}
{isLoading && <TypingBubble />}
<div ref={bottomRef} />
</div>
)
}
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터.
function TypingBubble() {
return (
<div className="mb-[56px] pt-[36px]" aria-label="협상 중" role="status">
<div className="inline-flex items-center gap-[6px]">
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.3s]" />
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.15s]" />
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce" />
</div>
</div>
)
}
const MessageItem = memo(function MessageItem({
message,
isFirst,
messages,
currentIndex,
}: {
message: ChatMessageType
isFirst: boolean
messages: ChatMessageType[]
currentIndex: number
}) {
const isBot = message.sender === 'bot'
// 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시
if (!isBot && currentIndex > 0) {
const prev = messages[currentIndex - 1]
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
return (
<>
<div className="text-right mb-[56px]" />
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
}
}
return <div>{isBot ? <BotMessage message={message} isFirst={isFirst} /> : <UserMessage text={message.script || ''} />}</div>
})
const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) {
return (
<div className="mb-[56px]">
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
<div className="body-1-read-r">{message.script || ''}</div>
</div>
<div className="flex flex-col gap-4 w-full mt-[32px]">
{message.bot_chat_type === 'indicator' && message.indicator_value != null && (
<Indicator number={message.indicator_value} />
)}
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
{message.bot_chat_type === 'summaryCM' && message.summary && (
<BidSummary
itemName={message.summary.item_name}
itemCode={message.summary.item_code}
bidPrice={message.summary.final_price}
deliveryType={message.summary.delivery_type || ''}
isVAT={message.summary.item_isVAT}
/>
)}
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
</div>
</div>
)
})
const UserMessage = memo(function UserMessage({ text }: { text: string }) {
return (
<>
<div className="text-right mb-[56px]">
<div className="inline-block max-w-[87%] bg-neutral-40 text-neutral-90 text-lg leading-[150%] tracking-[-0.18px] px-[20px] py-[10px] rounded-full break-keep">
{text}
</div>
</div>
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
</>
)
})