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:
parent
8f070e362b
commit
f7f5439042
@ -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 로 담는다 */
|
||||
|
||||
37
frontend/src/components/ErrorPage.tsx
Normal file
37
frontend/src/components/ErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user