15 KiB
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> — 예외 안 던짐(삼킴).
- Step 1: 실패 테스트 추가 —
snap.stream.test.ts에 아래 describe 추가.@/lib/api/client를 mock.
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 가 없으면 추가.
-
Step 2: 실패 확인 —
npx vitest run src/features/snap/api/snap.stream.test.ts→ FAIL (cancelStream 없음) -
Step 3: 구현 —
snap.stream.ts상단 import 에apiPost추가, 파일 끝에 함수 추가:
import { apiPost } from "@/lib/api/client"
// 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 —
// 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨.
export async function cancelStream(sessionId: string): Promise<void> {
try {
await apiPost(`/chat/sessions/${sessionId}/cancel`, {})
} catch {
// 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성.
}
}
- Step 4: 통과 확인 — 위 명령 PASS.
- Step 5: lint+커밋
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.
- Step 1: 실패 테스트 추가 —
snapChatStore.test.ts에 추가(기존 스타일; 각 it 앞reset()):
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)
})
-
Step 2: 실패 확인 —
npx vitest run src/features/snap/store/snapChatStore.test.ts→ FAIL. -
Step 3: 구현 —
snapChatStore.ts수정: 3-1.SnapChatState인터페이스에 추가(isStreaming근처 + 액션):
isRevealing: boolean
setRevealing: (v: boolean) => void
3-2. 초기값에 isRevealing: false, 추가(isStreaming: false, 옆).
3-3. seed 의 set({...}) 에 isRevealing: false, 추가(기존 isStreaming: false, 옆).
3-4. startAssistantMessage 를 isRevealing 도 켜게:
startAssistantMessage: () =>
set((s) => ({
messages: [...s.messages, { id: randomId(), role: "assistant" as SnapRole, content: "" }],
isRevealing: true,
})),
3-5. setStreaming 아래에 setRevealing 추가:
setRevealing: (v) => set({ isRevealing: v }),
3-6. stop() 을 가드 + isRevealing clear 로 교체:
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 도 끄게:
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, 추가.
- Step 4: 통과 확인 — 위 명령 PASS(기존 테스트 포함).
- Step 5: lint+커밋
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 유지).
- Step 1: 실패 테스트 추가 —
useSnapChat.test.tsx에 추가. 상단에import * as streamApi from "../api/snap.stream"는 이미vi.mock("../api/snap.stream")로 자동 mock 됨 →cancelStream도 mock 됨.
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 용.
-
Step 2: 실패 확인 —
npx vitest run src/features/snap/hooks/useSnapChat.test.tsx→ FAIL(retry/cancel 없음). -
Step 3: 구현 —
useSnapChat.ts: 3-1. import 에cancelStream추가:
import { snapStream, cancelStream } from "../api/snap.stream"
3-2. send 의 finally 는 그대로(이미 isStreaming 만 끔; isRevealing 은 안 건드림 — 유지 확인만).
3-3. stop 을 교체:
const stop = useCallback(() => {
useSnapChatStore.getState().stop()
void cancelStream(sessionId) // 백엔드 취소 통보(best-effort)
}, [sessionId])
3-4. retry 추가(return 위):
const retry = useCallback(() => {
const q = useSnapChatStore.getState().getRetryQuery()
if (q) void send(q)
}, [send])
3-5. return 을 { send, stop, retry } 로.
- Step 4: 통과 확인 — 위 명령 PASS(기존 테스트 포함).
- Step 5: lint+커밋
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.
- Step 1: import 추가 — SessionChatPage 상단:
import { StreamingText, StoppedNotice } from "@/lib/streaming"
그리고 useSnapChat 구조분해에 retry 추가: const { send, stop, retry } = useSnapChat(id).
- Step 2: isRevealing 구독 — 셀렉터 확장:
const { messages, isStreaming, isRevealing } = useSnapChatStore(
useShallow((s) => ({ messages: s.messages, isStreaming: s.isStreaming, isRevealing: s.isRevealing })),
)
const busy = isStreaming || isRevealing
- Step 3: 라이브/리빌 판정 교체 — 기존
liveLastIdx와isLiveLast를busy기준으로: 3-1.liveLastIdx조건의isStreaming &&를busy &&로 바꾼다(마지막 assistant + !frozen 은 유지). 3-2. 렌더 루프의isLiveLast:
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
3-3. 라이브 버블의 <StreamingText text={m.content} isStreaming /> 를 아래로(타이핑 pace 는 isStreaming, 리빌 끝 통지 추가):
<StreamingText
text={m.content}
isStreaming={isStreaming}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
- Step 4: frozen 버블 아래 StoppedNotice(옵션 B) — 루프 끝
return <Message .../>를, frozen assistant 면 StoppedNotice 를 곁들이게:
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} />
-
Step 5: Composer busy — Composer 렌더에
busy={busy}전달(기존isStreaming넘기던 자리 교체). onStop 은 그대로stop. (Composer.tsx 자체는 이미busy/onStopprop 을 받으므로 컴포넌트 수정 불필요 — 넘기는 값만busy.) -
Step 6: 검증
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
전부 통과.
- Step 7: 커밋
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)만 동작.