# Snap Mate Client (React 이식) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** new-chat.html 3화면 채팅 클라이언트를 기존 2_frontend 위에 `features/snap/` 모듈로 이식 — mock 데이터·비주얼 UI/UX 완성, 백엔드는 계약만 뚫어두고 나중. **Architecture:** 전용 풀블리드 라우트(`/snap`·`/snap/new`·`/snap/s/:id`)에 새 feature 모듈. 스트리밍은 기존 `lib/streaming/streamLLM` 재사용, 세션 API는 base-backend(000) 계약에 타입만 정렬하고 mock 반환. 기존 `features/chat`·대시보드는 안 건드림. **Tech Stack:** React 18 + TS + Vite + Tailwind + shadcn/ui + Zustand + TanStack Query + react-router-dom v6 + react-markdown/remark-gfm + date-fns + sonner + lucide-react. ## Global Constraints - 작업 디렉토리: `D:\project\021.code-assistant-v2\2_frontend` (모든 경로는 이 기준 상대경로). - **git repo 있음** (루트 `D:\project\021.code-assistant-v2`, 브랜치 `feat/snap-mate-client`) → 태스크마다 자기 파일만 add 후 커밋(push 금지). 각 태스크 게이트 = 자기 파일 `npx eslint` 클린 + 해당 테스트 통과 + Wave 경계에서 `npm run build`. (UI 태스크는 브라우저 육안 확인 추가.) - **baseline lint 주의**: 프로젝트 전체 `npm run lint`(eslint .)에는 기존 에러 10개·경고 9개가 있음 — 전부 이번 작업과 무관한 파일(`features/files/components/FileDropZone.tsx`, `features/skill-mapping/*`). 건드리지 말고 무시. 게이트는 항상 `npx eslint <자기 파일>`로 스코프. - 응답·주석 톤: 한글 반말 (CLAUDE.md 0번). camelCase 직렬화(백엔드 CamelModel 정렬). - **기존 파일 최소 수정**: 이번 작업으로 건드리는 기존 파일은 `src/config/routes.ts`(PATHS 추가)·`src/routes.tsx`(라우트 그룹 추가) **딱 2개**. `features/chat/*`·`chatStore`·`DashboardLayout` 절대 수정 금지. - mock 격리: 모든 mock 은 `src/features/snap/mock/` 한 폴더. 스왑 지점은 `snap.api.ts`·`snap.stream.ts` 주석 `// TODO(backend)` 로 표시. - import 별칭: `@/` → `src/`. 유틸: `cn` = `@/lib/utils/cn`, `randomId` = `@/lib/utils/randomId`. - dev 서버: `npm run dev` → http://localhost:15173. - 테스트: vitest. 로직(store·stream·api·util)만 TDD, 순수 프레젠테이션 컴포넌트는 브라우저 육안 + lint/build 로 검증. --- ## 파일 구조 (신규) ``` src/features/snap/ contract/types.ts # 계약 타입 (base-backend schema.py 정렬) mock/sessions.ts # SnapSession[] mock (+UI-only 필드) mock/conversations.ts # Record mock (markdown) mock/stream.ts # mock 토큰 방출기 store/snapChatStore.ts # 현재 세션 1개의 messages + 스트리밍 상태 api/snap.stream.ts # snapStream() — mock/real 스위치 api/snap.api.ts # useSessionList/useSessionMessages/useCreateSession hooks/useSnapChat.ts # 전송 오케스트레이션 (store+stream 배선) components/SnapLayout.tsx # 풀블리드 셸 components/CodeBlock.tsx # 코드블럭 + 복사 components/Message.tsx # markdown 렌더 components/Composer.tsx # 공용 입력창 components/SessionCard.tsx # 홈 세션 카드 components/SessionSearch.tsx# 홈 검색창 components/ChatHeader.tsx # 채팅 헤더 (back/title/badge) components/NavRail.tsx # 코드블럭 네비게이터 components/DetailPanel.tsx # 세션 메타 Sheet components/ClipBanner.tsx # 클립보드 감지 배너 components/Hero.tsx # 새 대화 히어로 components/SuggestCard.tsx # 추천 카드 pages/SessionListPage.tsx # /snap pages/SessionChatPage.tsx # /snap/s/:id pages/NewChatPage.tsx # /snap/new ``` 기존 수정: `src/config/routes.ts`, `src/routes.tsx`, `src/lib/utils/relativeTime.ts`(신규 유틸). --- ## Wave 실행 맵 | Wave | Task | 병렬성 | 파일(disjoint) | 의존 | |---|---|---|---|---| | 1 | T1 기반(types·PATHS·util) | 단독 | contract/types, config/routes(수정), lib/utils/relativeTime | — | | 2 | T2 mock 데이터 | 병렬 | mock/sessions, mock/conversations | T1 | | 2 | T3 store | 병렬 | store/snapChatStore | T1 | | 2 | T4 stream | 병렬 | api/snap.stream, mock/stream | T1 | | 3 | T5 api 훅 | 병렬 | api/snap.api | T1,T2 | | 3 | T6 Message+CodeBlock | 병렬 | components/Message, components/CodeBlock | T1 | | 3 | T7 Composer | 병렬 | components/Composer | — | | 3 | T8 셸+라우트+페이지 stub | 병렬 | components/SnapLayout, routes.tsx(수정), pages/*(stub) | T1 | | 4 | T9 홈 vertical | 병렬 | components/SessionCard, SessionSearch, pages/SessionListPage | T5,T8 | | 4 | T10 채팅 vertical | 병렬 | components/ChatHeader,NavRail,DetailPanel,ClipBanner, hooks/useSnapChat, pages/SessionChatPage | T3,T4,T5,T6,T7,T8 | | 4 | T11 새대화 vertical | 병렬 | components/Hero,SuggestCard, pages/NewChatPage | T5,T7,T8 | 같은 Wave = 파일 disjoint + 시그니처 의존 없음. Wave 경계에서 전체 `npm run lint && npm run build` 게이트. --- ### Task 1: 기반 — 계약 타입 · PATHS · 상대시간 유틸 **Files:** - Create: `src/features/snap/contract/types.ts` - Create: `src/lib/utils/relativeTime.ts` - Create: `src/lib/utils/relativeTime.test.ts` - Modify: `src/config/routes.ts` (PATHS 객체에 3줄 추가) **Interfaces:** - Produces: 타입 `SnapRole`, `SnapSession`, `SnapMessage`, `SnapSessionDetail`, `SnapStreamRequest`; `PATHS.SNAP`/`SNAP_NEW`/`SNAP_SESSION`; `formatRelativeKo(iso: string): string`. - Consumes: `date-fns`(설치됨). - [ ] **Step 1: 계약 타입 작성** Create `src/features/snap/contract/types.ts`: ```ts // base-backend modules/chat/schema.py 와 1:1 (camelCase). UI-only 필드는 명시 표기. export type SnapRole = "user" | "assistant" | "system" export interface SnapSession { id: string title: string | null titleLlm: string | null isGenerating: boolean createdAt: string updatedAt: string // --- UI-only: 백엔드에 없음. real 스왑 시 드롭 또는 파생(snippet=마지막메시지) --- tag?: string snippet?: string tokens?: string } export interface SnapMessage { sessionId: string role: SnapRole content: string // markdown createdAt: string } export interface SnapSessionDetail extends SnapSession { messages: SnapMessage[] } export interface SnapStreamRequest { sessionId: string content: string forcedSkill?: string } ``` - [ ] **Step 2: 상대시간 유틸 실패 테스트 작성** Create `src/lib/utils/relativeTime.test.ts`: ```ts import { describe, it, expect } from "vitest" import { formatRelativeKo } from "./relativeTime" describe("formatRelativeKo", () => { it("몇 분 전 한글 접미사를 낸다", () => { const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString() const out = formatRelativeKo(fiveMinAgo) expect(out).toContain("분") expect(out).toContain("전") }) }) ``` - [ ] **Step 3: 테스트 실패 확인** Run: `npm run test -- src/lib/utils/relativeTime.test.ts` Expected: FAIL — `formatRelativeKo` not found / 모듈 없음. - [ ] **Step 4: 유틸 구현** Create `src/lib/utils/relativeTime.ts`: ```ts import { formatDistanceToNow } from "date-fns" import { ko } from "date-fns/locale" /** ISO 시각을 "5분 전" 같은 한글 상대시간으로. */ export function formatRelativeKo(iso: string): string { return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ko }) } ``` - [ ] **Step 5: 테스트 통과 확인** Run: `npm run test -- src/lib/utils/relativeTime.test.ts` Expected: PASS. - [ ] **Step 6: PATHS 추가** Modify `src/config/routes.ts` — `PATHS` 객체 안 `SETTINGS_THEME` 줄 아래에 추가: ```ts SETTINGS_THEME: "/settings/theme", SNAP: "/snap", SNAP_NEW: "/snap/new", SNAP_SESSION: "/snap/s/:id", } as const ``` - [ ] **Step 7: 타입 체크 게이트** Run: `npm run lint` Expected: 에러 없음. --- ### Task 2: mock 데이터 (세션 목록 · 과거대화) **Files:** - Create: `src/features/snap/mock/sessions.ts` - Create: `src/features/snap/mock/conversations.ts` - Create: `src/features/snap/mock/mock.test.ts` **Interfaces:** - Consumes: `SnapSession`, `SnapMessage` (T1). - Produces: `MOCK_SESSIONS: SnapSession[]`, `MOCK_CONVERSATIONS: Record`. - [ ] **Step 1: 세션 mock 작성** Create `src/features/snap/mock/sessions.ts`: ```ts import type { SnapSession } from "../contract/types" // createdAt/updatedAt 은 렌더 시 상대시간으로 포맷. 고정 ISO 로 재현성 확보. const T = (minAgo: number) => new Date(Date.parse("2026-07-16T14:00:00Z") - minAgo * 60_000).toISOString() // tag/snippet/tokens 는 UI-only (백엔드 없음). real 스왑 시 드롭/파생. export const MOCK_SESSIONS: SnapSession[] = [ { id: "review-abap", title: "Review ABAP Code", titleLlm: "Review ABAP Code", isGenerating: false, createdAt: T(9999), updatedAt: T(1), tag: "ABAP", tokens: "2.1K", snippet: "사용자 정의 ABAP 코드를 분석할 준비가 됐어. SELECT 루프 내부의 성능 문제를 검토함." }, { id: "cds-view", title: "Generate CDS View", titleLlm: "Generate CDS View", isGenerating: true, createdAt: T(9999), updatedAt: T(2), tag: "CDS Views", tokens: "1.2K", snippet: "SAP 개발과 관련해 뭘 도와줄까? ABAP 로직, CDS 뷰, 또는 에러 분석 중 뭐든 시작해봐." }, { id: "schema-qa", title: "SAP Schema Q&A", titleLlm: "SAP Schema Q&A", isGenerating: false, createdAt: T(9999), updatedAt: T(4), tag: "Architecture", tokens: "3.4K", snippet: "S/4HANA 테이블 스키마와 연관 관계를 질의함. VBAK / VBAP 헤더-아이템 조인을 설명." }, { id: "dump-analysis", title: "Short Dump 분석", titleLlm: "Short Dump 분석", isGenerating: false, createdAt: T(9999), updatedAt: T(12), tag: "Error", tokens: "1.8K", snippet: "런타임 오류 CX_SY_OPEN_SQL_DB — WHERE 절 없는 대량 SELECT 로 인한 타임아웃을 진단." }, { id: "amdp-perf", title: "AMDP 성능 튜닝", titleLlm: "AMDP 성능 튜닝", isGenerating: false, createdAt: T(9999), updatedAt: T(34), tag: "HANA", tokens: "2.9K", snippet: "AMDP 프로시저에서 스칼라 UDF 호출을 제거하고 셋 기반 로직으로 재작성해 응답시간 단축." }, { id: "rap-model", title: "RAP 비즈니스 객체", titleLlm: "RAP 비즈니스 객체", isGenerating: false, createdAt: T(9999), updatedAt: T(120), tag: "RAP", tokens: "5.2K", snippet: "Travel BO — CDS·프로젝션·@UI·BDL·핸들러 클래스·서비스 정의 등 6개 소스를 생성." }, ] ``` - [ ] **Step 2: 과거대화 mock 작성** Create `src/features/snap/mock/conversations.ts`: ```ts import type { SnapMessage } from "../contract/types" const now = "2026-07-16T14:00:00Z" const m = (sessionId: string, role: SnapMessage["role"], content: string): SnapMessage => ({ sessionId, role, content, createdAt: now, }) const CDS_VIEW = `**VBAK**(헤더)와 **VBAP**(아이템)를 \`vbeln\` 으로 내부 조인한 뷰야. 키는 문서번호+아이템번호로 잡았어. \`\`\`abap define view entity ZI_SalesDocItemJoin as select from vbak as Header inner join vbap as Item on Header.vbeln = Item.vbeln { key Header.vbeln as SalesDocument, key Item.posnr as Item, Header.kunnr as SoldToParty, Item.matnr as Material, Item.netwr as NetAmount } \`\`\` 필요한 필드만 노출해서 성능 확보했어. Fiori 노출이 필요하면 \`@UI\` 애노테이션을 추가해봐.` const DUMP = `전형적인 **WHERE 절 없는 대량 SELECT** 패턴이야. VBAP 전체를 메모리로 읽어서 DB 세션이 타임아웃 났어. 키 범위로 제한하고 필요한 컬럼만 조회해. \`\`\`abap SELECT vbeln posnr matnr kwmeng FROM vbap INTO TABLE @lt_items WHERE vbeln IN @s_vbeln AND erdat >= @lv_from. \`\`\`` // 코드 없는 세션 렌더 확인용으로 하나는 평문만. export const MOCK_CONVERSATIONS: Record = { "cds-view": [ m("cds-view", "assistant", "SAP 개발과 관련해 뭘 도와줄까? ABAP 로직, CDS 뷰, 또는 에러 분석 중 뭐든 시작해봐."), m("cds-view", "user", "판매 문서 헤더/아이템을 조인한 CDS 뷰 하나 만들어줘. VBAK, VBAP 기준으로."), m("cds-view", "assistant", CDS_VIEW), ], "dump-analysis": [ m("dump-analysis", "assistant", "런타임 오류 로그를 붙여넣어줘. ST22 short dump 를 분석해서 원인과 수정안을 제시할게."), m("dump-analysis", "user", "CX_SY_OPEN_SQL_DB 덤프가 떴어. 배치 실행 중에 타임아웃 난 것 같아."), m("dump-analysis", "assistant", DUMP), ], "review-abap": [ m("review-abap", "assistant", "사용자 정의 ABAP 코드를 붙여넣어줘. SELECT 루프·내부 테이블 성능을 검토할게."), ], } ``` - [ ] **Step 3: mock 정합성 테스트 작성 후 통과 확인** Create `src/features/snap/mock/mock.test.ts`: ```ts import { describe, it, expect } from "vitest" import { MOCK_SESSIONS } from "./sessions" import { MOCK_CONVERSATIONS } from "./conversations" describe("snap mock", () => { it("세션마다 id·title 이 있다", () => { expect(MOCK_SESSIONS.length).toBeGreaterThan(0) for (const s of MOCK_SESSIONS) { expect(s.id).toBeTruthy() expect(s.title).toBeTruthy() } }) it("과거대화 키는 세션 id 부분집합이다", () => { const ids = new Set(MOCK_SESSIONS.map((s) => s.id)) for (const key of Object.keys(MOCK_CONVERSATIONS)) { expect(ids.has(key)).toBe(true) } }) }) ``` Run: `npm run test -- src/features/snap/mock/mock.test.ts` Expected: PASS. - [ ] **Step 4: lint 게이트** Run: `npm run lint` Expected: 에러 없음. --- ### Task 3: snapChatStore (현재 세션 1개 상태) **Files:** - Create: `src/features/snap/store/snapChatStore.ts` - Create: `src/features/snap/store/snapChatStore.test.ts` **Interfaces:** - Consumes: `SnapMessage`, `SnapRole` (T1); `randomId` (`@/lib/utils/randomId`). - Produces: `useSnapChatStore` (zustand). 타입 `SnapChatMessage { id, role, content, frozen? }`. 액션: `seed(sessionId, messages)`, `addUserMessage(content)`, `startAssistantMessage()`, `appendChunk(chunk)`, `setStreaming(v)`, `setController(c)`, `stop()`, `reset()`, `getRetryQuery()`. - [ ] **Step 1: 실패 테스트 작성** Create `src/features/snap/store/snapChatStore.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest" import { useSnapChatStore } from "./snapChatStore" beforeEach(() => useSnapChatStore.getState().reset()) describe("snapChatStore", () => { it("seed 로 세션 과거대화를 채운다", () => { useSnapChatStore.getState().seed("s1", [ { sessionId: "s1", role: "user", content: "안녕", createdAt: "" }, ]) const s = useSnapChatStore.getState() expect(s.sessionId).toBe("s1") expect(s.messages).toHaveLength(1) expect(s.messages[0].content).toBe("안녕") }) it("user 메시지 + assistant placeholder + 청크 누적", () => { const st = useSnapChatStore.getState() st.addUserMessage("질문") st.startAssistantMessage() st.appendChunk("답") st.appendChunk("변") const msgs = useSnapChatStore.getState().messages expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"]) expect(msgs[1].content).toBe("답변") }) it("stop 은 마지막 assistant 를 frozen 처리한다", () => { const st = useSnapChatStore.getState() st.addUserMessage("q"); st.startAssistantMessage(); st.setStreaming(true) st.stop() const last = useSnapChatStore.getState().messages.at(-1)! expect(last.frozen).toBe(true) expect(useSnapChatStore.getState().isStreaming).toBe(false) }) }) ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test -- src/features/snap/store/snapChatStore.test.ts` Expected: FAIL — 모듈 없음. - [ ] **Step 3: store 구현** Create `src/features/snap/store/snapChatStore.ts`: ```ts import { create } from "zustand" import { randomId } from "@/lib/utils/randomId" import type { SnapMessage, SnapRole } from "../contract/types" export interface SnapChatMessage { id: string role: SnapRole content: string /** stop 으로 스트림을 그 자리에서 동결하면 true. */ frozen?: boolean } interface SnapChatState { sessionId: string | null messages: SnapChatMessage[] isStreaming: boolean currentController: AbortController | null seed: (sessionId: string, messages: SnapMessage[]) => void addUserMessage: (content: string) => void startAssistantMessage: () => void appendChunk: (chunk: string) => void setStreaming: (v: boolean) => void setController: (c: AbortController | null) => void stop: () => void reset: () => void getRetryQuery: () => string | null } export const useSnapChatStore = create((set, get) => ({ sessionId: null, messages: [], isStreaming: false, currentController: null, seed: (sessionId, messages) => set({ sessionId, messages: messages.map((m) => ({ id: randomId(), role: m.role, content: m.content })), isStreaming: false, currentController: null, }), addUserMessage: (content) => set((s) => ({ messages: [...s.messages, { id: randomId(), role: "user" as SnapRole, content }] })), startAssistantMessage: () => set((s) => ({ messages: [...s.messages, { id: randomId(), role: "assistant" as SnapRole, content: "" }] })), appendChunk: (chunk) => { const last = get().messages.at(-1) if (!last || last.role !== "assistant") return set((s) => { const next = [...s.messages] next[next.length - 1] = { ...last, content: last.content + chunk } return { messages: next } }) }, setStreaming: (v) => set({ isStreaming: v }), setController: (c) => set({ currentController: c }), stop: () => { 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, currentController: null, })) }, reset: () => { get().currentController?.abort() set({ sessionId: null, messages: [], isStreaming: false, currentController: null }) }, getRetryQuery: () => { const msgs = get().messages for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === "user") return msgs[i].content return null }, })) ``` - [ ] **Step 4: 테스트 통과 확인** Run: `npm run test -- src/features/snap/store/snapChatStore.test.ts` Expected: PASS. - [ ] **Step 5: lint 게이트** Run: `npm run lint` Expected: 에러 없음. --- ### Task 4: snapStream (mock 토큰 방출 + real 스위치) **Files:** - Create: `src/features/snap/api/snap.stream.ts` - Create: `src/features/snap/mock/stream.ts` - Create: `src/features/snap/api/snap.stream.test.ts` **Interfaces:** - Consumes: `SnapStreamRequest` (T1); `streamLLM` (`@/lib/streaming`). - Produces: `snapStream(req, handlers, opts?)`, 타입 `SnapStreamHandlers { onToken, onDone, onError? }`; `mockStream(...)`. - [ ] **Step 1: 실패 테스트 작성** Create `src/features/snap/api/snap.stream.test.ts`: ```ts import { describe, it, expect } from "vitest" import { snapStream } from "./snap.stream" describe("snapStream (mock)", () => { it("토큰을 흘리고 onDone 으로 끝난다", async () => { let acc = "" let done = false await snapStream( { sessionId: "s1", content: "hi" }, { onToken: (d) => (acc += d), onDone: () => (done = true) }, ) expect(acc.length).toBeGreaterThan(0) expect(done).toBe(true) }) it("abort 하면 done 없이 조기 종료된다", async () => { const ctrl = new AbortController() let done = false const p = snapStream( { sessionId: "s1", content: "hi" }, { onToken: () => ctrl.abort(), onDone: () => (done = true) }, { signal: ctrl.signal }, ) await p expect(done).toBe(false) }) }) ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test -- src/features/snap/api/snap.stream.test.ts` Expected: FAIL — 모듈 없음. - [ ] **Step 3: mock 방출기 구현** Create `src/features/snap/mock/stream.ts`: ```ts import type { SnapStreamRequest } from "../contract/types" import type { SnapStreamHandlers } from "../api/snap.stream" const REPLY = `요청을 분석했어. 아래는 재사용 가능한 골격이야: \`\`\`abap SELECT vbeln posnr matnr kwmeng FROM vbap INTO TABLE @lt_items WHERE vbeln IN @s_vbeln. \`\`\` 프로젝트 네이밍 규칙에 맞게 조정해봐. 더 필요한 필드 있으면 알려줘.` /** 실제 SSE 흉내 — 3자씩 24ms 간격으로 흘리고, signal.abort 시 done 없이 종료. */ export function mockStream( _req: SnapStreamRequest, handlers: SnapStreamHandlers, opts?: { signal?: AbortSignal }, ): Promise { return new Promise((resolve) => { const tokens = REPLY.match(/[\s\S]{1,3}/g) ?? [] let i = 0 const signal = opts?.signal if (signal?.aborted) return resolve() const timer = setInterval(() => { if (signal?.aborted) { clearInterval(timer) return resolve() } if (i >= tokens.length) { clearInterval(timer) handlers.onDone() return resolve() } handlers.onToken(tokens[i++]) }, 24) signal?.addEventListener( "abort", () => { clearInterval(timer) resolve() }, { once: true }, ) }) } ``` - [ ] **Step 4: snapStream 구현 (mock/real 스위치)** Create `src/features/snap/api/snap.stream.ts`: ```ts import { streamLLM } from "@/lib/streaming" import type { SnapStreamRequest } from "../contract/types" import { mockStream } from "../mock/stream" export interface SnapStreamHandlers { onToken: (delta: string) => void onDone: () => void onError?: (e: Error) => void } // TODO(backend): mock 제거 시 false 로. real 경로는 base-backend POST /chat/stream 계약. const USE_MOCK = true export function snapStream( req: SnapStreamRequest, handlers: SnapStreamHandlers, opts?: { signal?: AbortSignal }, ): Promise { if (USE_MOCK) return mockStream(req, handlers, opts) return streamLLM({ path: "/chat/stream", body: req, signal: opts?.signal, handlers: { onToken: handlers.onToken, onDone: () => handlers.onDone(), onError: handlers.onError, }, }) } ``` - [ ] **Step 5: 테스트 통과 확인** Run: `npm run test -- src/features/snap/api/snap.stream.test.ts` Expected: PASS (두 케이스). - [ ] **Step 6: lint 게이트** Run: `npm run lint` Expected: 에러 없음. --- ### Task 5: api 훅 (TanStack Query, mock 반환) **Files:** - Create: `src/features/snap/api/snap.api.ts` - Create: `src/features/snap/api/snap.api.test.tsx` **Interfaces:** - Consumes: `SnapSession`/`SnapSessionDetail` (T1); `MOCK_SESSIONS`/`MOCK_CONVERSATIONS` (T2); `randomId`. - Produces: `useSessionList()` → `UseQueryResult`; `useSessionMessages(id)` → `UseQueryResult`; `useCreateSession()` → `UseMutationResult`. - [ ] **Step 1: 실패 테스트 작성** Create `src/features/snap/api/snap.api.test.tsx`: ```tsx import { describe, it, expect } from "vitest" import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useSessionList } from "./snap.api" function wrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return {children} } describe("useSessionList", () => { it("mock 세션 목록을 반환한다", async () => { const { result } = renderHook(() => useSessionList(), { wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data!.length).toBeGreaterThan(0) expect(result.current.data![0].id).toBeTruthy() }) }) ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test -- src/features/snap/api/snap.api.test.tsx` Expected: FAIL — 모듈 없음. - [ ] **Step 3: api 훅 구현** Create `src/features/snap/api/snap.api.ts`: ```ts import { useQuery, useMutation } from "@tanstack/react-query" import { randomId } from "@/lib/utils/randomId" import type { SnapSession, SnapSessionDetail } from "../contract/types" import { MOCK_SESSIONS } from "../mock/sessions" import { MOCK_CONVERSATIONS } from "../mock/conversations" // TODO(backend): 아래 queryFn/mutationFn 본문을 axios(client) 호출로 교체. // GET /chat/sessions → SnapSession[] // GET /chat/sessions/{id}/messages → SnapSessionDetail // POST /chat/sessions → SnapSession // 시그니처·반환타입은 그대로 두면 화면 코드는 안 바뀜. export function useSessionList() { return useQuery({ queryKey: ["snap", "sessions"], queryFn: async (): Promise => MOCK_SESSIONS, }) } export function useSessionMessages(id: string) { return useQuery({ queryKey: ["snap", "session", id], enabled: !!id, queryFn: async (): Promise => { const sess = MOCK_SESSIONS.find((s) => s.id === id) ?? MOCK_SESSIONS[0] return { ...sess, messages: MOCK_CONVERSATIONS[id] ?? [] } }, }) } export function useCreateSession() { return useMutation({ mutationFn: async (): Promise => { const iso = new Date().toISOString() return { id: randomId(), title: null, titleLlm: null, isGenerating: false, createdAt: iso, updatedAt: iso, } }, }) } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `npm run test -- src/features/snap/api/snap.api.test.tsx` Expected: PASS. - [ ] **Step 5: lint 게이트** Run: `npm run lint` Expected: 에러 없음. --- ### Task 6: Message + CodeBlock (markdown 렌더) **Files:** - Create: `src/features/snap/components/CodeBlock.tsx` - Create: `src/features/snap/components/Message.tsx` **Interfaces:** - Consumes: `SnapRole` (T1); `react-markdown`/`remark-gfm`(설치됨); `cn`; `sonner`; `lucide-react`. - Produces: ``; ``. - [ ] **Step 1: CodeBlock 구현** Create `src/features/snap/components/CodeBlock.tsx`: ```tsx import { useState } from "react" import { Check, Copy } from "lucide-react" import { toast } from "sonner" interface Props { code: string lang?: string /** NavRail 점프 대상 식별용 인덱스. */ index?: number } export function CodeBlock({ code, lang, index }: Props) { const [copied, setCopied] = useState(false) const copy = async () => { try { await navigator.clipboard.writeText(code) setCopied(true) toast.success("클립보드에 복사됨") setTimeout(() => setCopied(false), 1400) } catch { toast.error("복사 실패") } } return (
{lang ?? "code"}
        {code}
      
) } ``` - [ ] **Step 2: Message 구현** Create `src/features/snap/components/Message.tsx`: ```tsx import ReactMarkdown from "react-markdown" import remarkGfm from "remark-gfm" import { cn } from "@/lib/utils/cn" import type { SnapRole } from "../contract/types" import { CodeBlock } from "./CodeBlock" interface Props { role: SnapRole content: string } export function Message({ role, content }: Props) { const isUser = role === "user" return (
{isUser ? "나 · YOU" : "SNAP MATE"}
{isUser ? ( content ) : (
<>{children}, code: ({ className, children }) => { const match = /language-(\w+)/.exec(className ?? "") if (match) { return } return ( {children} ) }, }} > {content}
)}
) } ``` - [ ] **Step 3: lint + build 게이트** Run: `npm run lint` Expected: 에러 없음. (렌더 확인은 T10 채팅 페이지에서 육안.) --- ### Task 7: Composer (공용 입력창) **Files:** - Create: `src/features/snap/components/Composer.tsx` **Interfaces:** - Consumes: `Button`(`@/shared/ui/button`); `lucide-react`. - Produces: `` — `onSend(text: string)`. - [ ] **Step 1: Composer 구현** Create `src/features/snap/components/Composer.tsx`: ```tsx import { useState } from "react" import { Send, Square } from "lucide-react" import { Button } from "@/shared/ui/button" interface Props { onSend: (text: string) => void busy?: boolean onStop?: () => void placeholder?: string } export function Composer({ onSend, busy, onStop, placeholder }: Props) { const [value, setValue] = useState("") const submit = () => { const t = value.trim() if (!t || busy) return setValue("") onSend(t) } return (