From e9e644b15b3481d49325973ba9575c3d8831dd6f Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Mon, 21 Sep 2026 13:24:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Esc=20=EB=A1=9C=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=83=9D=EC=84=B1=20=EC=A4=91=EB=8B=A8=20=E2=80=94?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=A4=91=20=EC=95=84=EB=8B=88=EB=A9=B4?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=EB=8C=80=EB=A1=9C=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중단 버튼·백엔드 cancel·OpenCode abort 체인은 이미 있었고 Esc 배선만 없었음. 서버만 생성 중(재진입, generatingRemotely)일 때도 Esc 가 cancel 을 보냄. Co-Authored-By: Claude Fable 5.1 --- .../snap/pages/SessionChatPage.esc.test.tsx | 55 +++++++++++++++++++ .../features/snap/pages/SessionChatPage.tsx | 10 +++- z-my-docs/work-log/2026-09/2026-09-21.md | 1 + 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 2_frontend/src/features/snap/pages/SessionChatPage.esc.test.tsx 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개 |