Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -0,0 +1,329 @@
# 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<void>` — 예외 안 던짐(삼킴).
- [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<void> {
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. 라이브 버블의 `<StreamingText text={m.content} isStreaming />` 를 아래로(타이핑 pace 는 isStreaming, 리빌 끝 통지 추가):
```tsx
<StreamingText
text={m.content}
isStreaming={isStreaming}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
```
- [x] **Step 4: frozen 버블 아래 StoppedNotice(옵션 B)** — 루프 끝 `return <Message .../>` 를, frozen assistant 면 StoppedNotice 를 곁들이게:
```tsx
if (m.frozen && m.role === "assistant") {
return (
<div key={m.id} className="flex flex-col gap-2">
<Message role={m.role} content={m.content} codeIndexBase={base} />
<StoppedNotice onRetry={retry} disabled={busy} />
</div>
)
}
return <Message key={m.id} role={m.role} content={m.content} codeIndexBase={base} />
```
- [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)만 동작.