# Snap Mate Client — React 이식 설계 > new-chat.html 목업(3뷰 채팅 클라이언트)을 기존 2_frontend 코드베이스 위에 React로 구현. > 최종 목적지: .NET 윈도우 애플리케이션의 웹뷰. 작성일: 2026-07-16 --- ## 1. 목표 & 범위 `new-chat.html`(958줄 self-contained 목업)의 경험을 React로 옮긴다. 3개 뷰: - **홈** — 세션 목록 + 검색 - **채팅** — 세션 열어 과거대화 + 코드블럭 + 진짜 스트리밍 전송 - **새 대화** — 히어로 + 추천카드 → 첫 전송 **방침: 3화면 비주얼을 목업만큼 꽉 채운다.** 백엔드는 무조건 나중, 전제는 mock. 부가 UI(nav 레일·detail 패널·클립보드 배너)도 **이번에 비주얼로 다 보이게** 만들되, 깊은 로직(단축키 세트·실시간 클립보드 OS감지·real 백엔드)만 가볍게/2차. ### 이번 범위 (비주얼 완성) - 전용 풀블리드 라우트 3개 (`/snap`, `/snap/new`, `/snap/s/:id`) - 세션 목록 + 검색(클라 필터, 클릭 진입) - 세션 열어 과거대화 렌더 (markdown + 코드블럭 + 복사) - 스트리밍 전송 (컨트랙트만 뚫고 mock 토큰 방출 — 백엔드는 나중에) - 새 대화 히어로 + 추천카드 → 전송 → 세션 진입 - **source-code 네비게이터 레일** — 코드블럭 목록 + 클릭 점프 (기본 동작까지) - **세션 detail 패널** (Sheet) — 배지 클릭 시 세션 메타 표시 - **클립보드 감지 배너** — 목업처럼 타이머 데모(2.6s 후 등장) 수준의 비주얼 ### 2차로 미룸 (범위 밖) real 백엔드 부착 · 키보드 단축키 세트(↑↓/Ctrl+J/Alt화살표/Tab순환/Ctrl+Shift+C) 배선 · 실시간 클립보드 OS 읽기 · syntax highlight 강화 · 가짜 OS 타이틀바 · rename/delete/search 엔드포인트 --- ## 2. 핵심 결정 (확정됨) | 항목 | 결정 | |---|---| | 디자인 | 기존 **shadcn 컴포넌트로 흡수**. 별도 CSS 팔레트 왕국 안 만듦. editorial 성격(serif 이탤릭 타이틀·mono 대문자 eyebrow·따뜻한 accent)은 Tailwind 클래스로만 얹음 | | 앱 셸 | **전용 풀블리드 라우트** (사이드바/DashboardLayout 밖). 기존 대시보드 라우트는 안 건드림 | | 백엔드 계약 | **base-backend(000)의 세션 기반 계약에 정렬**. 지금은 **CONTRACT만 뚫고 mock**, UX/UI 집중 후 백엔드 부착 | | 스트리밍 | 기존 `lib/streaming/streamLLM`(범용) 재사용. 기존 `useChatStream`/`chatStore`(무상태 `{messages}` 계약)는 안 건드림 | | 폰트 | 웹뷰 오프라인 대비 — 새 CDN 폰트 안 붙임. 기존/시스템 폰트 + serif 폴백 | --- ## 3. 백엔드 계약 (base-backend `modules/chat`) 현재 2_frontend 채팅은 **무상태**(`{messages}` 통째 전송, 세션 없음 — echo 시대)라 그대로 못 쓴다. base-backend는 **세션 기반**으로 진화했고, 이 UI는 그 계약에 맞춘다: ``` GET /chat/sessions → Envelope (목록, 페이지네이션) GET /chat/sessions/{id}/messages → Envelope POST /chat/sessions → Envelope (새 대화 = 세션 발급) PATCH /chat/sessions/{id} → rename (2차) DELETE /chat/sessions/{id} → soft delete (2차) GET /chat/sessions/search?query= → 메시지 검색 (2차, 지금은 클라 필터) POST /chat/stream {sessionId, content, forcedSkill?} → SSE ``` ### 타입 (base-backend schema.py 와 1:1, camelCase) ```ts interface ChatSessionResponse { id: string title: string | null titleLlm: string | null isGenerating: boolean createdAt: string updatedAt: string } interface ChatMessageResponse { sessionId: string role: "user" | "assistant" | "system" content: string // markdown createdAt: string } interface ChatSessionDetailResponse extends ChatSessionResponse { messages: ChatMessageResponse[] } interface SnapStreamRequest { sessionId: string; content: string; forcedSkill?: string } ``` ### 목업 ↔ 실제 필드 매핑 | 목업 세션카드 | 실제 | 처리 | |---|---|---| | title | `titleLlm ?? title` | ✓ | | time | `updatedAt` (상대시간 포맷, date-fns) | ✓ | | 라이브 dot | `isGenerating` | ✓ | | tag / tokens / snippet | **백엔드에 없음** | mock엔 넣되 `// UI-only` 표기. real 스왑 시 snippet=마지막 메시지 미리보기 / tag·tokens 드롭·파생 | --- ## 4. 모듈 구조 ``` src/features/snap/ contract/ types.ts # 위 계약 타입 (types/api.ts 에서 재사용/확장) mock/ # ← 목업 격리. 백엔드 부착 = 이 폴더만 지우고 api 본문 스왑 sessions.ts # ChatSessionResponse[] + UI-only 확장(tag/snippet) conversations.ts # Record (content=markdown, 코드펜스 포함) stream.ts # mock 토큰 방출기 (실제 SSE 흉내) api/ snap.api.ts # useSessionList / useSessionMessages / useCreateSession (TanStack Query) # 지금 mock 반환. 주석에 "real = axios 이 줄" 스왑 지점 명시 snap.stream.ts # send({sessionId,content,forcedSkill?}) → 지금 mock/stream / 나중 streamLLM('/chat/stream') store/ snapChatStore.ts # 현재 세션 1개의 messages + 스트리밍 상태 (chatStore 패턴 복제, 세션화) components/ SnapLayout.tsx # 풀블리드 셸 + Toaster (가짜 타이틀바 없음, 얇은 브랜드 스트립) SessionCard.tsx / SessionSearch.tsx ChatHeader.tsx / Composer.tsx Message.tsx # markdown 렌더 + 코드펜스 → CodeBlock. 라이브는 StreamingText CodeBlock.tsx # mono 블럭 + 복사 버튼(navigator.clipboard). highlight 최소 NavRail.tsx # source-code 네비게이터 — 코드블럭 목록 + 클릭 점프 DetailPanel.tsx # 세션 메타 Sheet (배지 클릭) ClipBanner.tsx # 클립보드 감지 배너 (타이머 데모 비주얼) Hero.tsx / SuggestCard.tsx pages/ SessionListPage.tsx # /snap NewChatPage.tsx # /snap/new SessionChatPage.tsx # /snap/s/:id index.ts ``` ### 재사용 (새로 안 짬) - `lib/streaming/streamLLM` — 범용 `{path, body, signal, handlers}` 러너. snap.stream 이 여기에 얹힘 - `lib/streaming` 의 `StreamingText`(타자기) · `StoppedNotice` · `ClarifyChoices` 렌더 - `shared/ui/*` shadcn 프리미티브 (Button/Input/Card/Badge/Separator) - `react-markdown` + `remark-gfm` (이미 설치됨) — 메시지 markdown 렌더 - `sonner` toast · `lucide-react` 아이콘 · `date-fns` 상대시간 ### 안 건드림 (surgical) - 기존 `features/chat/*` (무상태 계약 그대로, `/chat` 페이지 유지) - `shared/components/DashboardLayout` · `AppSidebar` · 기존 라우트 --- ## 5. 라우팅 & 셸 `config/routes.ts` PATHS 추가: ```ts SNAP: "/snap", SNAP_NEW: "/snap/new", SNAP_SESSION: "/snap/s/:id", ``` `routes.tsx`(라우트 정의)에 `ProtectedRoute`(인증 필요) 안, **`DashboardLayout` 밖**에 `SnapLayout`(Outlet) 라우트 그룹으로 추가. 3 페이지가 이 셸을 공유. 웹뷰에선 .NET이 진짜 창을 주므로 가짜 min/max/close 타이틀바는 버리고 얇은 브랜드 스트립만. --- ## 6. 스트리밍 흐름 `snapChatStore` = 기존 `chatStore` 패턴(messages / isStreaming / isRevealing / controller / stop / retry)을 복제하되 "현재 세션 1개" 모델. 기존 chatStore·chat 페이지는 안 건드림. - **세션 열기** (`/snap/s/:id`): `useSessionMessages(id)` → 받은 messages 로 store seed (스트리밍 아님) - **전송**: `snapChatStore.addUserMessage` + `startAssistantMessage` → `snap.stream.send({sessionId, content})` → onToken 마다 `appendChunk`. 라이브 버블은 `StreamingText` - **새 대화** (`/snap/new`): 첫 전송 시 `useCreateSession()` → 세션 id 받음 → `/snap/s/:id` 이동 → seed 없이 바로 전송 - 지금은 `snap.stream` 이 mock 토큰 방출. 백엔드 부착 = `streamLLM({ path:'/chat/stream', body:{sessionId,content,forcedSkill} })` 로 본문 교체 `forcedSkill`/clarify(라우팅 후보) 배선은 계약엔 넣되 UI(ClarifyChoices)는 2차에 붙여도 됨 — 슬라이스에선 토큰 스트림만 확실히. --- ## 7. 메시지 렌더링 지금 chat은 content가 평문(타자기)뿐. 목업엔 코드블럭이 있고 실제 LLM도 markdown을 뱉는다. - **`Message`**: assistant content 를 `react-markdown`+`remark-gfm` 로 렌더. 코드펜스는 `components.code` 로 가로채 `CodeBlock` 렌더. user 는 평문(whitespace-pre-wrap) - **`CodeBlock`**: 상단 바(lang 라벨 + 복사 버튼) + mono `
`. 복사 = `navigator.clipboard` + toast. 블럭 내부 스크롤(목업처럼). syntax 색은 최소(2차 강화)
- **라이브 스트리밍 중**: 부분 markdown 이 깨질 수 있으니, 완료 전까지는 `StreamingText`(평문 타자기) → done 후 markdown 재렌더. (구현 시 부분 markdown 렌더 허용 여부 결정 — 기본은 완료 후 markdown)

---

## 8. 디자인 방침 (shadcn 흡수)

기존 테마 토큰(light/dark) + shadcn 컴포넌트 그대로. editorial 정체성은 Tailwind 클래스로만:
- 타이틀: `font-serif italic`
- eyebrow/메타: mono 대문자 + tracking
- 카드/보더: 기존 토큰(`bg-card`/`border`), 목업의 따뜻한 종이톤은 accent 정도로만 차용
- 별도 `.css` 팔레트 파일 안 만듦. 필요하면 Tailwind config 토큰만 최소 추가

---

## 9. Mock 격리 원칙 ("금방 없앨 수 있도록")

- 모든 mock 은 `features/snap/mock/` 한 폴더에만
- api/stream 훅은 **base-backend 계약 타입**을 반환/수신 (mock 도 그 모양)
- 스왑 지점 = `snap.api.ts` / `snap.stream.ts` 안 `// TODO(backend): mock → real` 주석 한 곳씩
- UI-only 필드(tag/snippet/tokens)는 명시적으로 표기해 real 스왑 시 안전하게 드롭/파생

---

## 10. 검증 기준 (verify)

각 페이지가 "됐다"를 화면으로 보여줄 수 있어야 (CLAUDE.md 10번 세로슬라이스):
1. `/snap` → mock 세션 목록 렌더, 검색어 입력 시 클라 필터 동작, 카드 클릭 → `/snap/s/:id` 이동
2. `/snap/s/:id` → 해당 세션 mock 과거대화 표시, 코드블럭 복사 버튼 동작(클립보드+toast)
3. 채팅 입력 후 전송 → user 버블 + mock 스트리밍 assistant 버블(타자기)
4. `/snap/new` → 히어로+추천카드, 추천 클릭 시 입력창 채움, 첫 전송 → 히어로 사라지고 세션 진입
5. **nav 레일** → 코드블럭 있는 세션에서 레일에 블럭 목록 뜨고, 클릭 시 해당 블럭으로 스크롤 점프
6. **detail 패널** → 채팅 헤더 배지 클릭 시 세션 메타 Sheet 열림/닫힘
7. **클립보드 배너** → 채팅 진입 후 타이머로 배너 등장, 무시/붙여넣기 버튼 동작
8. `npm run lint` 깨끗 · `npm run build` 성공 · 기존 `/chat` 등 대시보드 라우트 안 깨짐

---

## 11. 열린 질문 (구현 중 확정)
- 라이브 스트리밍 중 부분 markdown 렌더 허용 여부 (기본: 완료 후 markdown, 스트리밍 중엔 평문 타자기)
- Tailwind config 에 warm accent 토큰 추가할지 vs 기존 토큰으로만
- 상대시간 포맷 로케일(ko) — date-fns `formatDistanceToNow`