feat(chat): 프론트 로드 에러 페이지·대기 애니메이션·indicator 소비

- ErrorPage 컴포넌트 추가: init/messages 로드 실패(서버/네트워크) 시 ChatContainer 가 에러 화면+재시도(refetch) 렌더 (기존엔 빈 채팅처럼 보임)
- 대기 표시: 채팅 영역 '협상 중' 타이핑 버블 + 입력 버튼 자리 '...' 점 애니메이션
- desync 에러코드(CHAT_INPUT_MODE_MISMATCH 1404 / CHAT_AGENT_TIMEOUT 1405) 추가 및 메시지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-19 17:32:58 +09:00
parent 8f070e362b
commit f7f5439042
7 changed files with 124 additions and 6 deletions

View File

@ -41,6 +41,8 @@ export const ErrorCode = {
CHAT_PRICE_OUT_OF_RANGE: 1401,
CHAT_AGENT_UNAVAILABLE: 1402,
CHAT_IN_PROGRESS: 1403,
CHAT_INPUT_MODE_MISMATCH: 1404,
CHAT_AGENT_TIMEOUT: 1405,
} as const
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
@ -62,6 +64,8 @@ const API_ERROR_MESSAGES: Record<number, string> = {
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
[ErrorCode.CHAT_INPUT_MODE_MISMATCH]: '입력 형식이 맞지 않습니다. 최신 대화 상태로 다시 불러옵니다.',
[ErrorCode.CHAT_AGENT_TIMEOUT]: '협상 응답이 지연되고 있습니다. 최신 상태를 다시 불러옵니다.',
}
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */

View File

@ -0,0 +1,37 @@
import { AlertTriangle } from 'lucide-react'
import { Button } from '@/components/Button'
export interface ErrorPageProps {
/** 상단 메시지(굵게). 기본: 일반 오류 문구. */
message?: string
/** 보조 설명. 기본: 재시도 안내. */
description?: string
/** 재시도 버튼 핸들러. 없으면 버튼 미표시. */
onRetry?: () => void
}
// 데이터 로드 실패(서버/네트워크 오류) 시 보여주는 에러 화면. 컨테이너를 가득 채운다(h-full).
export function ErrorPage({
message = '문제가 발생했습니다.',
description = '잠시 후 다시 시도해주세요.',
onRetry,
}: ErrorPageProps) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-6 p-12">
<div className="flex size-20 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-10 text-destructive" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<p className="title-2 text-destructive">{message}</p>
<p className="body-1 text-neutral-70">{description}</p>
</div>
{onRetry && (
<Button variant="primary" size="lg" onClick={onRetry}>
다시 시도
</Button>
)}
</div>
</div>
)
}

View File

@ -5,3 +5,5 @@ export { Modal } from '@/components/Modal'
export type { ModalProps } from '@/components/Modal'
export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo'
export { ErrorPage } from '@/components/ErrorPage'
export type { ErrorPageProps } from '@/components/ErrorPage'

View File

@ -21,10 +21,12 @@ export function ChatMessage() {
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])
}, [chats, isLoading])
if (!chats || chats.length === 0) {
return (
@ -39,11 +41,25 @@ function ChatList() {
{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,

View File

@ -1,5 +1,6 @@
import { useNavigate } from 'react-router'
import { List, Loader2 } from 'lucide-react'
import { List } from 'lucide-react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
@ -31,7 +32,7 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
)}
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <Loader2 className="size-8 animate-spin text-neutral-60" />}
{type === 'loading' && <LoadingDots />}
</div>
<div className="flex justify-end">
<GoToList />
@ -41,6 +42,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
)
}
// agent 응답 대기 중(협상 중) 버튼 자리에 표시하는 "..." 점 애니메이션. 비활성(클릭 불가).
function LoadingDots() {
return (
<div className={cn(style.black, 'cursor-default pointer-events-none')} aria-label="협상 중" role="status">
<span className="flex gap-[6px]">
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.3s]" />
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.15s]" />
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce" />
</span>
</div>
)
}
function GoToList() {
const navigate = useNavigate()
return (

View File

@ -1,10 +1,30 @@
import { Loader2 } from 'lucide-react'
import { ErrorPage } from '@/components'
import { useChatController } from '@/features/chat/hooks/useChatController'
import { ChatSection } from '@/features/chat/components/ChatSection'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
// 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도).
export function ChatContainer({ sessionId }: { sessionId: string }) {
useChatController(sessionId)
const { isInitLoading, initError, refetchInit } = useChatController(sessionId)
if (isInitLoading) {
return (
<div className="flex flex-1 items-center justify-center w-full">
<Loader2 className="size-10 animate-spin text-neutral-50" aria-label="불러오는 중" />
</div>
)
}
if (initError) {
return (
<div className="flex flex-1 w-full">
<ErrorPage message="채팅을 불러오는 중 오류가 발생했습니다." onRetry={refetchInit} />
</div>
)
}
return (
<div className="flex flex-1 min-h-0 w-full">
<ChatSection />

View File

@ -1,6 +1,13 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router'
import { useChatInitQuery, useChatMessagesQuery, useChatSendMutation, mapMessage } from '@/apis/chat'
import { useQueryClient } from '@tanstack/react-query'
import {
useChatInitQuery,
useChatMessagesQuery,
useChatSendMutation,
mapMessage,
chatKeys,
} from '@/apis/chat'
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
import { toast } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
@ -16,6 +23,12 @@ const TERMINAL_CODES = new Set<number>([
ErrorCode.NEGO_NOT_FOUND,
])
// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크.
const RESYNC_CODES = new Set<number>([
ErrorCode.CHAT_INPUT_MODE_MISMATCH, // 보낸 입력이 직전 봇이 요구한 모드와 불일치
ErrorCode.CHAT_AGENT_TIMEOUT, // agent 타임아웃(서버/agent 상태가 앞서 있을 수 있음)
])
let tempSeq = 0
// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다.
@ -42,6 +55,7 @@ function makeUserMessage(text: string, inputType: UserInputType): ChatMessage {
*/
export function useChatController(sessionId: string) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const setInitData = useChatInitStore((s) => s.setInitData)
const initQuery = useChatInitQuery(sessionId)
const messagesQuery = useChatMessagesQuery(sessionId)
@ -94,6 +108,12 @@ export function useChatController(sessionId: string) {
toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.'))
// 입력-모드 불일치/타임아웃: 서버 기준으로 대화를 다시 불러와 화면을 리싱크한다.
// (messages 쿼리가 갱신되면 위 effect 가 스토어 messages 를 덮어써 버튼/입력창이 서버 상태에 맞춰진다.)
if (RESYNC_CODES.has(code)) {
queryClient.invalidateQueries({ queryKey: chatKeys.messages(sessionId) })
}
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
if (TERMINAL_CODES.has(code)) {
s.bindSend(null) // 입력 잠금(추가 전송 차단)
@ -107,10 +127,15 @@ export function useChatController(sessionId: string) {
return () => {
useChatStore.getState().reset()
}
}, [sessionId, sendMutate, navigate])
}, [sessionId, sendMutate, navigate, queryClient])
return {
isInitLoading: initQuery.isLoading || messagesQuery.isLoading,
initError: initQuery.error ?? messagesQuery.error,
// 로드 실패(서버/네트워크) 시 init·messages 를 함께 재조회한다.
refetchInit: () => {
void initQuery.refetch()
void messagesQuery.refetch()
},
}
}