fix(frontend): 새 대화 전송 즉시 말풍선+'생각하는 중' 표시, 세션 생성 실패 시 헤로 복귀

세션 생성(OpenCode 왕복)이 느리면 헤로 화면에 '응답 중' 만 떠서 안 넘어간 것처럼 보였고,
실패하면 pending 이 안 풀려 영원히 갇혔음.
- 전송 순간 헤로 → 내 말풍선 + 시머(SessionChatPage 첫 화면과 같은 모양)
- mutate 콜백 패턴(SnapUserControls 와 동일): onSuccess 이동, onError 헤로 복귀 + 토스트
- 테스트 3개(훅 mock, SnippetPalettePage.test 패턴)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 15:30:55 +09:00
co-authored by Claude Fable 5.1
parent 458a828409
commit 5998944f09
3 changed files with 92 additions and 10 deletions
@@ -0,0 +1,55 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { act, fireEvent, render, screen } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import NewChatPage from "./NewChatPage"
// 전송 즉시 헤로 대신 내 말풍선+시머 — 세션 생성이 느려도 "넘어간" 느낌. 실패하면 헤로로 복귀.
// SnippetPalettePage.test 와 같이 훅을 통째 mock — mutate 콜백을 테스트가 직접 쥠.
const mutate = vi.fn()
vi.mock("../api/snap.api", () => ({ useCreateSession: () => ({ mutate, isPending: false }) }))
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
type Cb = { onSuccess: (s: { id: string }) => void; onError: () => void }
const lastCallbacks = () => mutate.mock.calls.at(-1)?.[1] as Cb
function mount() {
return render(
<MemoryRouter initialEntries={["/snap/new"]}>
<Routes>
<Route path="/snap/new" element={<NewChatPage />} />
<Route path="/snap/s/:id" element={<div>-</div>} />
</Routes>
</MemoryRouter>
)
}
describe("NewChatPage 전송", () => {
beforeEach(() => mutate.mockReset())
it("전송하면 세션 생성 중에도 내 질문과 '생각하는 중'이 보이고, 끝나면 세션 화면으로 간다", async () => {
mount()
fireEvent.click(screen.getByText("MARA 에서 자재번호로 자재유형(MTART) 읽는 SELECT SINGLE"))
expect(mutate).toHaveBeenCalledOnce()
expect(await screen.findByText("생각하는 중…")).toBeTruthy()
expect(screen.getByText("MARA 에서 자재번호로 자재유형(MTART) 읽는 SELECT SINGLE")).toBeTruthy()
expect(screen.queryByText("무엇을 도와드릴까요?")).toBeNull()
act(() => lastCallbacks().onSuccess({ id: "ses_new" }))
expect(await screen.findByText("세션화면-테스트")).toBeTruthy()
})
it("세션 생성 실패면 헤로로 돌아온다", async () => {
mount()
fireEvent.click(screen.getByText("내부 테이블 LOOP 에서 그룹 소계 구하는 관용구"))
expect(await screen.findByText("생각하는 중…")).toBeTruthy()
act(() => lastCallbacks().onError())
expect(await screen.findByText("무엇을 도와드릴까요?")).toBeTruthy()
expect(screen.queryByText("생각하는 중…")).toBeNull()
})
it("생성 중엔 두 번째 전송을 무시한다", () => {
mount()
fireEvent.click(screen.getByText("MARA 에서 자재번호로 자재유형(MTART) 읽는 SELECT SINGLE"))
// 헤로가 사라져 버튼은 없지만, Composer 의 onSend 경로도 pending 가드로 막힘 — mutate 는 1회
expect(mutate).toHaveBeenCalledTimes(1)
})
})
@@ -2,10 +2,12 @@ import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft } from "lucide-react"
import { PATHS } from "@/config/routes"
import { toast } from "sonner"
import { Kbd } from "@/shared/components/Kbd"
import { useCreateSession } from "../api/snap.api"
import { Hero } from "../components/Hero"
import { Composer } from "../components/Composer"
import { Message } from "../components/Message"
import { useEscapeKey } from "../hooks/useEscapeKey"
import type { SnapImageInput } from "../contract/types"
@@ -13,17 +15,26 @@ import type { SnapImageInput } from "../contract/types"
export default function NewChatPage() {
const navigate = useNavigate()
const createSession = useCreateSession()
const [pending, setPending] = useState(false)
// 전송 누른 순간의 질문 — 세션 생성(OpenCode 왕복) 동안 헤로 대신 말풍선+시머를 보여 "넘어간" 느낌을 줌
const [pending, setPending] = useState<string | null>(null)
// 단계적 Esc: 새 대화 화면에선 목록으로.
useEscapeKey(() => navigate(PATHS.SNAP))
// 첫 전송: 세션 생성 → 채팅 페이지로 이동하며 firstMessage 전달(거기서 스트림).
const start = async (text: string, images: SnapImageInput[]) => {
if (pending) return
setPending(true)
const session = await createSession.mutateAsync()
navigate(`/snap/s/${session.id}`, { state: { firstMessage: text, firstImages: images } })
// mutate + 콜백(SnapUserControls 의 logout 과 같은 패턴) — mutateAsync 는 거부가 밖으로 새서 안 씀.
const start = (text: string, images: SnapImageInput[]) => {
if (pending !== null) return
setPending(text.trim() || (images.length ? `이미지 ${images.length}` : "…"))
createSession.mutate(undefined, {
onSuccess: (session) =>
navigate(`/snap/s/${session.id}`, { state: { firstMessage: text, firstImages: images } }),
onError: () => {
// 세션 생성 실패(OpenCode 죽음 등) — 헤로로 되돌리고 알림. 안 풀면 "응답 중" 에 영원히 갇힘.
setPending(null)
toast.error("대화를 시작하지 못했어. 서버 상태를 확인하고 다시 보내봐.")
},
})
}
return (
@@ -39,12 +50,27 @@ export default function NewChatPage() {
<Kbd>Esc</Kbd>
</button>
</div>
<div className="grid min-h-0 flex-1 place-items-center overflow-y-auto px-5">
<Hero onPick={(text) => void start(text, [])} disabled={pending} />
</div>
{pending === null ? (
<div className="grid min-h-0 flex-1 place-items-center overflow-y-auto px-5">
<Hero onPick={(text) => start(text, [])} disabled={false} />
</div>
) : (
// 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중)
<div className="mx-auto flex min-h-0 w-full max-w-[760px] flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
<Message role="user" content={pending} />
<div className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
CODEASSIST
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
<span className="shimmer-text text-sm"> </span>
</div>
</div>
</div>
)}
<Composer
onSend={start}
busy={pending}
busy={pending !== null}
placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…"
capturePath={PATHS.SNAP_NEW}
/>
+1
View File
@@ -16,3 +16,4 @@
| 14:54 | 로컬도 이미지 요청만 vision 모델 — OPENCODE_VISION_MODEL(openrouter/google/gemma-4-31b-it), 백엔드가 prompt 에 model 실음. 텍스트는 GLM 5.2 그대로 |
| 14:58 | 팀원용 가이드 — ABAP_INDEXING 을 FabriX 로 돌리기. 직접 붙이면 401(헤더 3종)이라 CodeAssist 게이트웨이(/api/ito) 경유, LLM_MODEL=339, 확인 항목 3개 |
| 15:21 | taste-skill 설치·적용 — 랜딩용이라 대부분 해당 없고 3개만: 삼성 팔레트 글씨 순검정→#111, radius 규칙(버튼 알약/카드·입력 10px), 버튼 눌림 피드백 |
| 15:28 | 새 대화 전송 시 세션 생성(OpenCode 왕복) 동안 헤로에 머물던 것 — 즉시 내 말풍선+'생각하는 중' 시머로 바꿔 넘어간 느낌, 실패하면 헤로 복귀+토스트(전엔 pending 안 풀려 영원히 '응답 중'). mutate 콜백 패턴으로 |