feat(frontend): Esc 로 답변 생성 중단 — 생성 중 아니면 기존대로 목록 이동

중단 버튼·백엔드 cancel·OpenCode abort 체인은 이미 있었고 Esc 배선만 없었음.
서버만 생성 중(재진입, generatingRemotely)일 때도 Esc 가 cancel 을 보냄.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 13:24:55 +09:00
co-authored by Claude Fable 5.1
parent 8571db5bee
commit e9e644b15b
3 changed files with 63 additions and 3 deletions
@@ -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<typeof stream>()),
cancelStream: vi.fn().mockResolvedValue(undefined),
snapStream: vi.fn().mockResolvedValue(undefined),
}))
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/snap/s/s1"]}>
<Routes>
<Route path="/snap/s/:id" element={<SessionChatPage />} />
<Route path="/snap" element={<div>-</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
}
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()
})
})
@@ -22,9 +22,6 @@ export default function SessionChatPage() {
const [navBlocks, setNavBlocks] = useState<{ index: number; lang: string }[]>([])
const scrollRef = useRef<HTMLDivElement>(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