diff --git a/frontend/src/apis/types.ts b/frontend/src/apis/types.ts index 86d18ed..3c0f614 100644 --- a/frontend/src/apis/types.ts +++ b/frontend/src/apis/types.ts @@ -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 = { [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 로 담는다 */ diff --git a/frontend/src/components/ErrorPage.tsx b/frontend/src/components/ErrorPage.tsx new file mode 100644 index 0000000..42eccc5 --- /dev/null +++ b/frontend/src/components/ErrorPage.tsx @@ -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 ( +
+
+
+ +
+
+

{message}

+

{description}

+
+ {onRetry && ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index ac75f31..552fcff 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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' diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index b4cea65..66fcf19 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -21,10 +21,12 @@ export function ChatMessage() { function ChatList() { const bottomRef = useRef(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) => ( ))} + {isLoading && }
) } +// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터. +function TypingBubble() { + return ( +
+
+ + + +
+
+ ) +} + const MessageItem = memo(function MessageItem({ message, isFirst, diff --git a/frontend/src/features/chat/components/UserButton.tsx b/frontend/src/features/chat/components/UserButton.tsx index 064f75a..3ad6a0c 100644 --- a/frontend/src/features/chat/components/UserButton.tsx +++ b/frontend/src/features/chat/components/UserButton.tsx @@ -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 )} {type === 'price' && } - {type === 'loading' && } + {type === 'loading' && }
@@ -41,6 +42,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt ) } +// agent 응답 대기 중(협상 중) 버튼 자리에 표시하는 "..." 점 애니메이션. 비활성(클릭 불가). +function LoadingDots() { + return ( +
+ + + + + +
+ ) +} + function GoToList() { const navigate = useNavigate() return ( diff --git a/frontend/src/features/chat/containers/ChatContainer.tsx b/frontend/src/features/chat/containers/ChatContainer.tsx index 1159b38..c0f4ddb 100644 --- a/frontend/src/features/chat/containers/ChatContainer.tsx +++ b/frontend/src/features/chat/containers/ChatContainer.tsx @@ -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 ( +
+ +
+ ) + } + + if (initError) { + return ( +
+ +
+ ) + } + return (
diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index 05c31f3..88a183a 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -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([ ErrorCode.NEGO_NOT_FOUND, ]) +// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크. +const RESYNC_CODES = new Set([ + 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() + }, } }