# Snap 중단(Stop) UX chat 수준 이식 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox 표기. **Goal:** snap 중단 UX를 chat 수준으로 — `isRevealing` 2-플래그(네트워크 끝난 뒤 타이핑 tail 동안도 중단 가능), 중단 시 `StoppedNotice`+재시도, 그리고 백엔드 취소 엔드포인트를 best-effort로 호출(미구현이면 조용히 degrade). **Architecture:** frozen 버블은 **옵션 B**(받은 전체를 `Message`로 유지 → CodeBlock 복사·NavRail 점프 유지) + 아래 `StoppedNotice`. 라이브/타이핑 중 마지막 assistant 버블만 `StreamingText`. `stop()`은 로컬 freeze + `cancelStream(sessionId)`(POST /chat/sessions/{id}/cancel, 실패 삼킴). **Tech Stack:** React 18 + TS + Zustand + TanStack Query + vitest. ## Global Constraints - 작업 디렉토리 `D:\project\021.code-assistant-v2\2_frontend`. 브랜치 `feat/snap-stop-ux`. push 금지. - 게이트: 자기 파일 `npx eslint` + 해당 테스트 통과. 마지막 UI 태스크는 `npm run build` + `npx tsc --noEmit`. - 한글 반말 주석. 결합 순차(병렬 금지). - 백엔드 취소 엔드포인트 계약: `POST /api/v1/chat/sessions/{sessionId}/cancel` (auth CurrentUser, is_generating 즉시 clear, 부분 저장 권장). **아직 미구현** — 프론트는 best-effort 호출로 degrade. ## Wave 실행 맵 (전부 순차 — 결합) | 순서 | Task | 파일 | 의존 | |---|---|---|---| | 1 | cancelStream | `api/snap.stream.ts(+test)` | — | | 2 | store isRevealing | `store/snapChatStore.ts(+test)` | — | | 3 | useSnapChat stop+retry | `hooks/useSnapChat.ts(+test)` | 1,2 | | 4 | 페이지/Composer 배선 | `pages/SessionChatPage.tsx`,`components/Composer.tsx` | 2,3 | --- ## Task 1: cancelStream (best-effort 취소 호출) **Files:** Modify `src/features/snap/api/snap.stream.ts` · Test `src/features/snap/api/snap.stream.test.ts` **Produces:** `cancelStream(sessionId: string): Promise` — 예외 안 던짐(삼킴). - [x] **Step 1: 실패 테스트 추가** — `snap.stream.test.ts` 에 아래 describe 추가. `@/lib/api/client` 를 mock. ```ts import * as client from "@/lib/api/client" import { cancelStream } from "./snap.stream" vi.mock("@/lib/api/client", () => ({ apiPost: vi.fn() })) describe("cancelStream", () => { beforeEach(() => vi.clearAllMocks()) it("POST /chat/sessions/{id}/cancel 를 호출한다", async () => { vi.mocked(client.apiPost).mockResolvedValue(null) await cancelStream("s1") expect(client.apiPost).toHaveBeenCalledWith("/chat/sessions/s1/cancel", {}) }) it("실패해도 throw 하지 않는다(best-effort)", async () => { vi.mocked(client.apiPost).mockRejectedValue(new Error("404")) await expect(cancelStream("s1")).resolves.toBeUndefined() }) }) ``` 주의: 기존 `snap.stream.test.ts` 는 `vi.mock("@/lib/streaming", ...)` 를 이미 씀. 위 `vi.mock("@/lib/api/client", ...)` 를 추가하고, 상단 import 에 `beforeEach` 가 없으면 추가. - [x] **Step 2: 실패 확인** — `npx vitest run src/features/snap/api/snap.stream.test.ts` → FAIL (cancelStream 없음) - [x] **Step 3: 구현** — `snap.stream.ts` 상단 import 에 `apiPost` 추가, 파일 끝에 함수 추가: ```ts import { apiPost } from "@/lib/api/client" ``` ```ts // 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 — // 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨. export async function cancelStream(sessionId: string): Promise { try { await apiPost(`/chat/sessions/${sessionId}/cancel`, {}) } catch { // 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성. } } ``` - [x] **Step 4: 통과 확인** — 위 명령 PASS. - [x] **Step 5: lint+커밋** ```bash npx eslint src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts git add src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts git commit -m "feat(snap): add best-effort cancelStream for backend cancel endpoint" ``` --- ## Task 2: snapChatStore — isRevealing 2-플래그 **Files:** Modify `src/features/snap/store/snapChatStore.ts` · Test `src/features/snap/store/snapChatStore.test.ts` **Produces:** state `isRevealing: boolean`; action `setRevealing(v: boolean)`; `startAssistantMessage` 가 `isRevealing=true`; `stop()` 가 `isStreaming||isRevealing` 가드 + 둘 다 false + 마지막 assistant frozen; `dropEmptyAssistantTail` 가 드롭 시 `isRevealing=false` 도 clear. - [x] **Step 1: 실패 테스트 추가** — `snapChatStore.test.ts` 에 추가(기존 스타일; 각 it 앞 `reset()`): ```ts it("startAssistantMessage 는 isRevealing 을 켠다", () => { const s = useSnapChatStore.getState() s.reset() s.startAssistantMessage() expect(useSnapChatStore.getState().isRevealing).toBe(true) }) it("setRevealing 으로 끌 수 있다", () => { const s = useSnapChatStore.getState() s.reset() s.setRevealing(true) s.setRevealing(false) expect(useSnapChatStore.getState().isRevealing).toBe(false) }) it("stop 은 isStreaming/isRevealing 을 모두 끄고 마지막 assistant 를 frozen 처리", () => { const s = useSnapChatStore.getState() s.reset() s.addUserMessage("hi") s.startAssistantMessage() s.appendChunk("부분") s.setStreaming(true) useSnapChatStore.getState().stop() const st = useSnapChatStore.getState() expect(st.isStreaming).toBe(false) expect(st.isRevealing).toBe(false) expect(st.messages.at(-1)!.frozen).toBe(true) }) it("dropEmptyAssistantTail 은 드롭 시 isRevealing 도 끈다", () => { const s = useSnapChatStore.getState() s.reset() s.addUserMessage("hi") s.startAssistantMessage() // isRevealing=true, 빈 assistant useSnapChatStore.getState().dropEmptyAssistantTail() expect(useSnapChatStore.getState().isRevealing).toBe(false) }) ``` - [x] **Step 2: 실패 확인** — `npx vitest run src/features/snap/store/snapChatStore.test.ts` → FAIL. - [x] **Step 3: 구현** — `snapChatStore.ts` 수정: 3-1. `SnapChatState` 인터페이스에 추가(`isStreaming` 근처 + 액션): ```ts isRevealing: boolean ``` ```ts setRevealing: (v: boolean) => void ``` 3-2. 초기값에 `isRevealing: false,` 추가(`isStreaming: false,` 옆). 3-3. `seed` 의 `set({...})` 에 `isRevealing: false,` 추가(기존 `isStreaming: false,` 옆). 3-4. `startAssistantMessage` 를 isRevealing 도 켜게: ```ts startAssistantMessage: () => set((s) => ({ messages: [...s.messages, { id: randomId(), role: "assistant" as SnapRole, content: "" }], isRevealing: true, })), ``` 3-5. `setStreaming` 아래에 `setRevealing` 추가: ```ts setRevealing: (v) => set({ isRevealing: v }), ``` 3-6. `stop()` 을 가드 + isRevealing clear 로 교체: ```ts stop: () => { if (!get().isStreaming && !get().isRevealing) return get().currentController?.abort() set((s) => ({ messages: s.messages.map((m, i) => i === s.messages.length - 1 && m.role === "assistant" ? { ...m, frozen: true } : m, ), isStreaming: false, isRevealing: false, currentController: null, })) }, ``` 3-7. `dropEmptyAssistantTail` 이 드롭 시 isRevealing 도 끄게: ```ts dropEmptyAssistantTail: () => { const last = get().messages.at(-1) if (!last || last.role !== "assistant" || last.content !== "") return set((s) => ({ messages: s.messages.slice(0, -1), isRevealing: false })) }, ``` 3-8. `reset()` 의 `set({...})` 에 `isRevealing: false,` 추가. - [x] **Step 4: 통과 확인** — 위 명령 PASS(기존 테스트 포함). - [x] **Step 5: lint+커밋** ```bash npx eslint src/features/snap/store/snapChatStore.ts src/features/snap/store/snapChatStore.test.ts git add src/features/snap/store/snapChatStore.ts src/features/snap/store/snapChatStore.test.ts git commit -m "feat(snap): add isRevealing two-flag to chat store for post-network typewriter" ``` --- ## Task 3: useSnapChat — stop→cancel, retry, isRevealing 유지 **Files:** Modify `src/features/snap/hooks/useSnapChat.ts` · Test `src/features/snap/hooks/useSnapChat.test.tsx` **Consumes:** `cancelStream`(T1), store `isRevealing`/`setRevealing`/`stop`(T2). **Produces:** `useSnapChat(id)` → `{ send, stop, retry }`. `stop` = store.stop() + `cancelStream(id)`. `retry` = 마지막 유저 재전송. `send` 의 `finally` 는 `isStreaming` 만 끔(isRevealing 유지). - [x] **Step 1: 실패 테스트 추가** — `useSnapChat.test.tsx` 에 추가. 상단에 `import * as streamApi from "../api/snap.stream"` 는 이미 `vi.mock("../api/snap.stream")` 로 자동 mock 됨 → `cancelStream` 도 mock 됨. ```ts it("stop 은 store.stop 후 cancelStream(id) 를 부른다", async () => { useSnapChatStore.getState().setStreaming(true) const { result } = renderHook(() => useSnapChat("s1"), { wrapper }) result.current.stop() expect(vi.mocked(stream.cancelStream)).toHaveBeenCalledWith("s1") expect(useSnapChatStore.getState().isStreaming).toBe(false) }) it("retry 는 마지막 유저 메시지를 다시 보낸다", async () => { useSnapChatStore.getState().reset() useSnapChatStore.getState().addUserMessage("원래 질문") vi.mocked(stream.snapStream).mockResolvedValue(undefined) const { result } = renderHook(() => useSnapChat("s1"), { wrapper }) await result.current.retry() expect(vi.mocked(stream.snapStream)).toHaveBeenCalled() const req = vi.mocked(stream.snapStream).mock.calls[0][0] expect(req.content).toBe("원래 질문") }) ``` 주의: `vi.mock("../api/snap.stream")` 는 자동 mock 이라 `cancelStream`·`snapStream` 둘 다 `vi.fn()`. 기존 테스트가 `vi.mock("../api/snap.stream")` 를 이미 선언했으면 그대로 사용. `retry` 는 동기 반환(void)이라 `await result.current.retry()` 가능하도록 아래 구현은 `retry` 를 `() => void` 로 둔다(내부에서 `void send(...)`). 테스트의 `await`는 마이크로태스크 flush 용. - [x] **Step 2: 실패 확인** — `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx` → FAIL(retry/cancel 없음). - [x] **Step 3: 구현** — `useSnapChat.ts`: 3-1. import 에 `cancelStream` 추가: ```ts import { snapStream, cancelStream } from "../api/snap.stream" ``` 3-2. `send` 의 `finally` 는 그대로(이미 isStreaming 만 끔; isRevealing 은 안 건드림 — 유지 확인만). 3-3. `stop` 을 교체: ```ts const stop = useCallback(() => { useSnapChatStore.getState().stop() void cancelStream(sessionId) // 백엔드 취소 통보(best-effort) }, [sessionId]) ``` 3-4. `retry` 추가(return 위): ```ts const retry = useCallback(() => { const q = useSnapChatStore.getState().getRetryQuery() if (q) void send(q) }, [send]) ``` 3-5. return 을 `{ send, stop, retry }` 로. - [x] **Step 4: 통과 확인** — 위 명령 PASS(기존 테스트 포함). - [x] **Step 5: lint+커밋** ```bash npx eslint src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx git add src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx git commit -m "feat(snap): stop fires cancelStream, add retry, keep isRevealing" ``` --- ## Task 4: SessionChatPage + Composer 배선 **Files:** Modify `src/features/snap/pages/SessionChatPage.tsx`, `src/features/snap/components/Composer.tsx` **Consumes:** store `isRevealing`/`setRevealing`(T2), `retry`(T3), `StoppedNotice` from `@/lib/streaming`. - [x] **Step 1: import 추가** — SessionChatPage 상단: ```ts import { StreamingText, StoppedNotice } from "@/lib/streaming" ``` 그리고 `useSnapChat` 구조분해에 `retry` 추가: `const { send, stop, retry } = useSnapChat(id)`. - [x] **Step 2: isRevealing 구독** — 셀렉터 확장: ```ts const { messages, isStreaming, isRevealing } = useSnapChatStore( useShallow((s) => ({ messages: s.messages, isStreaming: s.isStreaming, isRevealing: s.isRevealing })), ) const busy = isStreaming || isRevealing ``` - [x] **Step 3: 라이브/리빌 판정 교체** — 기존 `liveLastIdx` 와 `isLiveLast` 를 `busy` 기준으로: 3-1. `liveLastIdx` 조건의 `isStreaming &&` 를 `busy &&` 로 바꾼다(마지막 assistant + !frozen 은 유지). 3-2. 렌더 루프의 `isLiveLast`: ```ts const isLiveLast = busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen ``` 3-3. 라이브 버블의 `` 를 아래로(타이핑 pace 는 isStreaming, 리빌 끝 통지 추가): ```tsx useSnapChatStore.getState().setRevealing(false)} /> ``` - [x] **Step 4: frozen 버블 아래 StoppedNotice(옵션 B)** — 루프 끝 `return ` 를, frozen assistant 면 StoppedNotice 를 곁들이게: ```tsx if (m.frozen && m.role === "assistant") { return (
) } return ``` - [x] **Step 5: Composer busy** — Composer 렌더에 `busy={busy}` 전달(기존 `isStreaming` 넘기던 자리 교체). onStop 은 그대로 `stop`. (Composer.tsx 자체는 이미 `busy`/`onStop` prop 을 받으므로 컴포넌트 수정 불필요 — 넘기는 값만 `busy`.) - [x] **Step 6: 검증** ```bash npx tsc --noEmit npx eslint src/features/snap/pages/SessionChatPage.tsx src/features/snap/components/Composer.tsx npx vitest run src/features/snap npm run build ``` 전부 통과. - [x] **Step 7: 커밋** ```bash git add src/features/snap/pages/SessionChatPage.tsx src/features/snap/components/Composer.tsx git commit -m "feat(snap): wire stop UX — isRevealing busy, live typewriter reveal, StoppedNotice+retry" ``` --- ## 검증(수동) — ✅ 완료 확인 백엔드 8001 + `npm run dev`(15173) → 세션에서 스트리밍 중 **중단** → 부분 답변 유지 + "응답이 중단되었습니다" + 재시도 버튼. 타이핑 tail 도는 동안에도 중단 버튼 유지. 재시도 → 마지막 질문 다시 전송(백엔드 취소 미구현이면 409 토스트). 백엔드 cancel 구현 후엔 재시도 즉시 동작. ## 미룸 - ~~출력속도(instant) 토글~~ — 완료. - 백엔드 `POST /chat/sessions/{id}/cancel` 구현(사용자 담당) — **추후로 미룸**. 미구현이라 재시도는 best-effort(로컬 freeze)만 동작.