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:
co-authored by
Claude Fable 5.1
parent
458a828409
commit
5998944f09
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user