diff --git a/2_frontend/src/features/snap/pages/NewChatPage.test.tsx b/2_frontend/src/features/snap/pages/NewChatPage.test.tsx new file mode 100644 index 0000000..7567bfd --- /dev/null +++ b/2_frontend/src/features/snap/pages/NewChatPage.test.tsx @@ -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( + + + } /> + 세션화면-테스트} /> + + + ) +} + +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) + }) +}) diff --git a/2_frontend/src/features/snap/pages/NewChatPage.tsx b/2_frontend/src/features/snap/pages/NewChatPage.tsx index a8ded70..fe270a9 100644 --- a/2_frontend/src/features/snap/pages/NewChatPage.tsx +++ b/2_frontend/src/features/snap/pages/NewChatPage.tsx @@ -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(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() { Esc -
- void start(text, [])} disabled={pending} /> -
+ {pending === null ? ( +
+ start(text, [])} disabled={false} /> +
+ ) : ( + // 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중) +
+ +
+ + CODEASSIST + +
+ 생각하는 중… +
+
+
+ )} diff --git a/z-my-docs/work-log/2026-09/2026-09-21.md b/z-my-docs/work-log/2026-09/2026-09-21.md index 4783228..dc87951 100644 --- a/z-my-docs/work-log/2026-09/2026-09-21.md +++ b/z-my-docs/work-log/2026-09/2026-09-21.md @@ -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 콜백 패턴으로 |