11 KiB
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<ChatSessionResponse[]> (목록, 페이지네이션)
GET /chat/sessions/{id}/messages → Envelope<ChatSessionDetailResponse>
POST /chat/sessions → Envelope<ChatSessionResponse> (새 대화 = 세션 발급)
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)
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<sessionId, ChatMessageResponse[]> (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 렌더sonnertoast ·lucide-react아이콘 ·date-fns상대시간
안 건드림 (surgical)
- 기존
features/chat/*(무상태 계약 그대로,/chat페이지 유지) shared/components/DashboardLayout·AppSidebar· 기존 라우트
5. 라우팅 & 셸
config/routes.ts PATHS 추가:
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<pre>. 복사 =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번 세로슬라이스):
/snap→ mock 세션 목록 렌더, 검색어 입력 시 클라 필터 동작, 카드 클릭 →/snap/s/:id이동/snap/s/:id→ 해당 세션 mock 과거대화 표시, 코드블럭 복사 버튼 동작(클립보드+toast)- 채팅 입력 후 전송 → user 버블 + mock 스트리밍 assistant 버블(타자기)
/snap/new→ 히어로+추천카드, 추천 클릭 시 입력창 채움, 첫 전송 → 히어로 사라지고 세션 진입- nav 레일 → 코드블럭 있는 세션에서 레일에 블럭 목록 뜨고, 클릭 시 해당 블럭으로 스크롤 점프
- detail 패널 → 채팅 헤더 배지 클릭 시 세션 메타 Sheet 열림/닫힘
- 클립보드 배너 → 채팅 진입 후 타이머로 배너 등장, 무시/붙여넣기 버튼 동작
npm run lint깨끗 ·npm run build성공 · 기존/chat등 대시보드 라우트 안 깨짐
11. 열린 질문 (구현 중 확정)
- 라이브 스트리밍 중 부분 markdown 렌더 허용 여부 (기본: 완료 후 markdown, 스트리밍 중엔 평문 타자기)
- Tailwind config 에 warm accent 토큰 추가할지 vs 기존 토큰으로만
- 상대시간 포맷 로케일(ko) — date-fns
formatDistanceToNow