330 lines
15 KiB
Markdown
330 lines
15 KiB
Markdown
# 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)만 동작.
|