Files
CODE_ASSISTANT/docs/superpowers/plans/2026-07-18-snap-stop-ux.md
T
2026-09-16 17:22:14 +09:00

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.tsvi.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); startAssistantMessageisRevealing=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. seedset({...})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 = 마지막 유저 재전송. sendfinallyisStreaming 만 끔(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. sendfinally 는 그대로(이미 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: 라이브/리빌 판정 교체 — 기존 liveLastIdxisLiveLastbusy 기준으로: 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/onStop prop 을 받으므로 컴포넌트 수정 불필요 — 넘기는 값만 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)만 동작.