diff --git a/2_frontend/src/features/snap/pages/SessionChatPage.esc.test.tsx b/2_frontend/src/features/snap/pages/SessionChatPage.esc.test.tsx new file mode 100644 index 0000000..0e05b28 --- /dev/null +++ b/2_frontend/src/features/snap/pages/SessionChatPage.esc.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" +import { act, fireEvent, render } from "@testing-library/react" +import { MemoryRouter, Route, Routes } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import SessionChatPage from "./SessionChatPage" +import { useSnapChatStore } from "../store/snapChatStore" +import * as stream from "../api/snap.stream" + +// Esc 는 답변 생성 중이면 중단, 아니면 목록으로 — 클로드처럼. +// NavRail 안 ClipboardHistory 가 IndexedDB 를 써서(jsdom 없음) 레일은 비움. +vi.mock("../components/NavRail", () => ({ NavRail: () => null })) +vi.mock("../api/snap.stream", async (orig) => ({ + ...(await orig()), + cancelStream: vi.fn().mockResolvedValue(undefined), + snapStream: vi.fn().mockResolvedValue(undefined), +})) + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + } /> + 목록화면-테스트} /> + + + + ) +} + +describe("SessionChatPage Esc", () => { + beforeEach(() => { + useSnapChatStore.getState().reset() + vi.mocked(stream.cancelStream).mockClear() + }) + + it("스트리밍 중 Esc 는 중단(cancelStream)하고 화면에 남는다", () => { + const { queryByText } = mount() + act(() => { + useSnapChatStore.getState().setStreaming(true) + }) + fireEvent.keyDown(window, { key: "Escape" }) + expect(vi.mocked(stream.cancelStream)).toHaveBeenCalledWith("s1") + expect(useSnapChatStore.getState().isStreaming).toBe(false) + expect(queryByText("목록화면-테스트")).toBeNull() + }) + + it("답변 중이 아니면 Esc 는 목록으로 간다", () => { + const { getByText } = mount() + fireEvent.keyDown(window, { key: "Escape" }) + expect(vi.mocked(stream.cancelStream)).not.toHaveBeenCalled() + expect(getByText("목록화면-테스트")).toBeTruthy() + }) +}) diff --git a/2_frontend/src/features/snap/pages/SessionChatPage.tsx b/2_frontend/src/features/snap/pages/SessionChatPage.tsx index 6994d3c..f02b7c8 100644 --- a/2_frontend/src/features/snap/pages/SessionChatPage.tsx +++ b/2_frontend/src/features/snap/pages/SessionChatPage.tsx @@ -22,9 +22,6 @@ export default function SessionChatPage() { const [navBlocks, setNavBlocks] = useState<{ index: number; lang: string }[]>([]) const scrollRef = useRef(null) - // 단계적 Esc: 대화창에선 목록/검색 화면으로(창은 목록에서 Esc 로 숨김). - useEscapeKey(() => navigate(PATHS.SNAP)) - const { messages, isStreaming, isRevealing } = useSnapChatStore( useShallow((s) => ({ messages: s.messages, @@ -37,6 +34,13 @@ export default function SessionChatPage() { // 시머로 "생각 중" 표시 + 전송 잠금(보내면 어차피 409). 폴링이 완성본을 곧 가져옴. const generatingRemotely = !busy && !!detail?.isGenerating && messages.at(-1)?.role === "user" + // 단계적 Esc: 답변 생성 중이면 중단(클로드처럼), 아니면 목록/검색 화면으로(창은 목록에서 Esc 로 숨김). + // 재진입해서 서버만 생성 중(generatingRemotely)일 때도 Esc 가 백엔드 취소를 보냄. + useEscapeKey(() => { + if (busy || generatingRemotely) stop() + else navigate(PATHS.SNAP) + }) + // 세션 진입: 과거대화 seed. NewChatPage 에서 넘어온 firstMessage 있으면 seed 없이 바로 전송. const firstRequest = location.state as { firstMessage?: string 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 bb76f00..6d12a44 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 @@ -6,3 +6,4 @@ | 10:26 | 삼성향 라이트 팔레트 추가·기본으로 — 삼성닷컴 실측 토큰(검정 CTA·흰 바탕·#f7f7f7 면·#ddd 테두리), 알약 radius 20px. 다크는 그대로. 폰트는 라이선스 근거 없어 미적용 | | 10:40 | PwC 로고·마크 전부 제거(LogoMark 빈 렌더, 파비콘 투명), 기본 모드 라이트로(예전 dark 저장값 한 번 초기화), 삼성 팔레트 포인트를 Samsung Blue #1428A0 로(버튼·링·링크) | | 10:43 | SAP MCP 를 -12 에 따로 띄우는 준비 — OpenCode 템플릿 sap-icf 를 MCP_API_KEY 유무로 on/off 렌더, .env.example SAP 블록, README 절차(-13 번들 재활용, 컨테이너 간 통신 없이) | +| 13:24 | Esc 로 답변 중단 — 생성 중이면 Esc 가 중단(클로드처럼), 아니면 목록으로. 재진입 상태에서도 백엔드 cancel. 페이지 테스트 2개 |