Files
CODE_ASSISTANT/docs/superpowers/plans/2026-07-16-snap-mate-react-client.md
T
2026-09-16 17:22:14 +09:00

1757 lines
65 KiB
Markdown

# 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<id, SnapMessage[]> 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<string, SnapMessage[]>`.
- [ ] **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<string, SnapMessage[]> = {
"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<SnapChatState>((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<void> {
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<void> {
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<SnapSession[]>`; `useSessionMessages(id)``UseQueryResult<SnapSessionDetail>`; `useCreateSession()``UseMutationResult<SnapSession, ...>`.
- [ ] **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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
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<SnapSession[]> => MOCK_SESSIONS,
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: async (): Promise<SnapSessionDetail> => {
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<SnapSession> => {
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: `<CodeBlock code lang? />`; `<Message role content />`.
- [ ] **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 (
<div
data-code-block={index ?? 0}
className="my-2 overflow-hidden rounded-md border border-border bg-zinc-900 text-zinc-100"
>
<div className="sticky top-0 flex items-center gap-2 border-b border-black/40 bg-zinc-800/80 px-3 py-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-zinc-400">{lang ?? "code"}</span>
<button
type="button"
onClick={copy}
className="ml-auto inline-flex items-center gap-1 rounded border border-zinc-700 px-2 py-0.5 font-mono text-[10px] text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "복사됨" : "복사"}
</button>
</div>
<pre className="max-h-80 overflow-auto p-3 font-mono text-xs leading-relaxed">
<code>{code}</code>
</pre>
</div>
)
}
```
- [ ] **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 (
<div className={cn("flex flex-col gap-1", isUser ? "items-end" : "items-start")}>
<span className="font-mono text-[9.5px] uppercase tracking-widest text-muted-foreground">
{isUser ? "나 · YOU" : "SNAP MATE"}
</span>
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
isUser ? "whitespace-pre-wrap bg-primary text-primary-foreground" : "border border-border bg-muted",
)}
>
{isUser ? (
content
) : (
<div className="[&_li]:my-0.5 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 first:[&_p]:mt-0 last:[&_p]:mb-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className ?? "")
if (match) {
return <CodeBlock code={String(children).replace(/\n$/, "")} lang={match[1]} />
}
return (
<code className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10">
{children}
</code>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
)}
</div>
</div>
)
}
```
- [ ] **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: `<Composer onSend busy? onStop? placeholder? />``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 (
<div className="flex-none border-t border-border bg-card p-3">
<div className="rounded-lg border border-border bg-background p-2 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
rows={1}
disabled={busy}
placeholder={busy ? "응답 중…" : (placeholder ?? "메시지를 입력하세요…")}
className="max-h-32 w-full resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<div className="mt-2 flex items-center">
<span className="font-mono text-[10px] tracking-wide text-muted-foreground">
Enter 전송 · Shift+Enter 줄바꿈
</span>
{busy ? (
<Button type="button" size="sm" variant="destructive" className="ml-auto" onClick={onStop}>
<Square className="size-3.5 fill-current" />
중단
</Button>
) : (
<Button type="button" size="sm" className="ml-auto" disabled={!value.trim()} onClick={submit}>
<Send className="size-3.5" />
전송
</Button>
)}
</div>
</div>
</div>
)
}
```
- [ ] **Step 2: lint 게이트**
Run: `npm run lint`
Expected: 에러 없음.
---
### Task 8: SnapLayout + 라우트 + 페이지 stub
**Files:**
- Create: `src/features/snap/components/SnapLayout.tsx`
- Create: `src/features/snap/pages/SessionListPage.tsx` (stub)
- Create: `src/features/snap/pages/SessionChatPage.tsx` (stub)
- Create: `src/features/snap/pages/NewChatPage.tsx` (stub)
- Modify: `src/routes.tsx`
**Interfaces:**
- Consumes: `PATHS` (T1); `ProtectedRoute`(기존).
- Produces: `<SnapLayout />` (Outlet 셸); 3개 default-export 페이지 컴포넌트 (stub, Wave 4 에서 살 붙임); `/snap`·`/snap/new`·`/snap/s/:id` 라우트.
- [ ] **Step 1: SnapLayout 구현**
Create `src/features/snap/components/SnapLayout.tsx`:
```tsx
import { Outlet } from "react-router-dom"
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
export function SnapLayout() {
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<header className="flex h-9 flex-none items-center gap-2 border-b border-border px-4">
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">SNAP MATE</span>
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-[10px] tracking-wide text-emerald-600">
<span className="size-1.5 rounded-full bg-emerald-500" />
HANA-LIVE
</span>
</header>
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
)
}
```
- [ ] **Step 2: 페이지 stub 3개 생성**
Create `src/features/snap/pages/SessionListPage.tsx`:
```tsx
export default function SessionListPage() {
return <div className="p-6 font-mono text-sm text-muted-foreground">SessionListPage (stub)</div>
}
```
Create `src/features/snap/pages/SessionChatPage.tsx`:
```tsx
export default function SessionChatPage() {
return <div className="p-6 font-mono text-sm text-muted-foreground">SessionChatPage (stub)</div>
}
```
Create `src/features/snap/pages/NewChatPage.tsx`:
```tsx
export default function NewChatPage() {
return <div className="p-6 font-mono text-sm text-muted-foreground">NewChatPage (stub)</div>
}
```
- [ ] **Step 3: 라우트 배선**
Modify `src/routes.tsx`.
(a) lazy import 블록(다른 lazy 선언들 아래)에 추가:
```tsx
const SessionListPage = lazy(() => import("@/features/snap/pages/SessionListPage"))
const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
```
그리고 상단 정적 import 에 SnapLayout 추가:
```tsx
import { SnapLayout } from "@/features/snap/components/SnapLayout"
```
(b) `<ProtectedRoute />``children` 배열에서, 기존 `{ element: <DashboardLayout /> , ... }` **앞에** SnapLayout 그룹을 형제로 추가:
```tsx
{
element: <ProtectedRoute />,
children: [
{
element: <SnapLayout />,
children: [
{ path: PATHS.SNAP.slice(1), element: wrap(<SessionListPage />) },
{ path: PATHS.SNAP_NEW.slice(1), element: wrap(<NewChatPage />) },
{ path: PATHS.SNAP_SESSION.slice(1), element: wrap(<SessionChatPage />) },
],
},
{
element: <DashboardLayout />,
children: [
// ...기존 그대로...
],
},
],
},
```
- [ ] **Step 4: 라우트 동작 확인 (브라우저)**
Run: `npm run dev` 후 http://localhost:15173/snap · `/snap/new` · `/snap/s/cds-view` 접속(로그인 필요 시 로그인).
Expected: 상단 "SNAP MATE · HANA-LIVE" 스트립 + 각 stub 텍스트 보임. 기존 `/`·`/chat` 등 대시보드 정상.
- [ ] **Step 5: lint + build 게이트**
Run: `npm run lint && npm run build`
Expected: 성공.
---
### Task 9: 홈 vertical (세션 목록 + 검색)
**Files:**
- Create: `src/features/snap/components/SessionCard.tsx`
- Create: `src/features/snap/components/SessionSearch.tsx`
- Modify: `src/features/snap/pages/SessionListPage.tsx` (stub → 실제)
**Interfaces:**
- Consumes: `useSessionList` (T5); `SnapSession` (T1); `formatRelativeKo` (T1); `PATHS`; `react-router-dom`(useNavigate).
- Produces: `<SessionCard session onOpen />`; `<SessionSearch value onChange />`; 완성된 `SessionListPage`.
- [ ] **Step 1: SessionSearch 구현**
Create `src/features/snap/components/SessionSearch.tsx`:
```tsx
import { Search } from "lucide-react"
interface Props {
value: string
onChange: (v: string) => void
}
export function SessionSearch({ value, onChange }: Props) {
return (
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="대화 세션 내용 검색…"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:border-ring focus:ring-2 focus:ring-ring/20"
/>
</div>
)
}
```
- [ ] **Step 2: SessionCard 구현**
Create `src/features/snap/components/SessionCard.tsx`:
```tsx
import { MessageSquare, ChevronRight } from "lucide-react"
import type { SnapSession } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
session: SnapSession
onOpen: (id: string) => void
}
export function SessionCard({ session, onOpen }: Props) {
const title = session.titleLlm ?? session.title ?? "제목 없음"
return (
<button
type="button"
onClick={() => onOpen(session.id)}
className="flex w-full items-start gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-ring"
>
<MessageSquare className="mt-0.5 size-4 flex-none text-muted-foreground" />
<span className="flex min-w-0 flex-col">
<span className="mb-0.5 truncate text-sm font-semibold">{title}</span>
{session.snippet && <span className="line-clamp-1 text-xs text-muted-foreground">{session.snippet}</span>}
<span className="mt-1.5 flex items-center gap-2">
{session.tag && (
<span className="rounded border border-border px-1.5 font-mono text-[9.5px] uppercase tracking-wide text-muted-foreground">
{session.tag}
</span>
)}
<span className="font-mono text-[9.5px] text-muted-foreground">{formatRelativeKo(session.updatedAt)}</span>
{session.isGenerating && <span className="size-1.5 rounded-full bg-emerald-500" title="생성 중" />}
</span>
</span>
<ChevronRight className="mt-1 size-3.5 flex-none self-center text-muted-foreground" />
</button>
)
}
```
- [ ] **Step 3: SessionListPage 구현**
Replace `src/features/snap/pages/SessionListPage.tsx`:
```tsx
import { useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Plus } from "lucide-react"
import { PATHS } from "@/config/routes"
import { useSessionList } from "../api/snap.api"
import { SessionSearch } from "../components/SessionSearch"
import { SessionCard } from "../components/SessionCard"
export default function SessionListPage() {
const navigate = useNavigate()
const { data: sessions = [], isLoading } = useSessionList()
const [q, setQ] = useState("")
const visible = useMemo(() => {
const query = q.trim().toLowerCase()
if (!query) return sessions
return sessions.filter((s) =>
`${s.titleLlm ?? ""} ${s.title ?? ""} ${s.snippet ?? ""} ${s.tag ?? ""}`.toLowerCase().includes(query),
)
}, [sessions, q])
return (
<div className="mx-auto flex h-full max-w-2xl flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-center gap-3">
<div className="grid size-9 flex-none place-items-center rounded-md bg-primary font-serif text-lg font-bold italic text-primary-foreground">
S
</div>
<div className="flex flex-col">
<span className="font-serif text-base font-semibold italic">지난 대화</span>
<span className="font-mono text-[9.5px] uppercase tracking-widest text-muted-foreground">Snap Mate · Workspace</span>
</div>
<button
type="button"
onClick={() => navigate(PATHS.SNAP_NEW)}
title="새 대화"
className="ml-auto grid size-9 flex-none place-items-center rounded-md bg-primary text-primary-foreground transition-opacity hover:opacity-90"
>
<Plus className="size-4" />
</button>
</div>
<SessionSearch value={q} onChange={setQ} />
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">
{q ? "검색 결과" : "최근 진행 대화"}
</span>
<span className="rounded-full border border-border px-2 font-mono text-[10px] text-muted-foreground">{visible.length}</span>
</div>
<div className="flex flex-col gap-2">
{isLoading && <div className="py-8 text-center font-mono text-xs text-muted-foreground">불러오는 중…</div>}
{!isLoading && visible.length === 0 && (
<div className="py-8 text-center font-mono text-xs text-muted-foreground">검색 결과가 없습니다 · NO MATCH</div>
)}
{visible.map((s) => (
<SessionCard key={s.id} session={s} onOpen={(id) => navigate(`/snap/s/${id}`)} />
))}
</div>
</div>
)
}
```
- [ ] **Step 4: 홈 동작 확인 (브라우저)**
Run: `npm run dev` → http://localhost:15173/snap
Expected: mock 세션 카드 목록 렌더, 검색어 입력 시 필터, 상단 카운트 갱신, 카드 클릭 시 `/snap/s/:id` 이동(stub 또는 T10 완료 시 실제 채팅). `+` 클릭 시 `/snap/new` 이동.
- [ ] **Step 5: lint + build 게이트**
Run: `npm run lint && npm run build`
Expected: 성공.
---
### Task 10: 채팅 vertical (과거대화 · 스트리밍 · 레일 · 패널 · 배너)
**Files:**
- Create: `src/features/snap/hooks/useSnapChat.ts`
- Create: `src/features/snap/components/ChatHeader.tsx`
- Create: `src/features/snap/components/NavRail.tsx`
- Create: `src/features/snap/components/DetailPanel.tsx`
- Create: `src/features/snap/components/ClipBanner.tsx`
- Modify: `src/features/snap/pages/SessionChatPage.tsx` (stub → 실제)
**Interfaces:**
- Consumes: `useSnapChatStore` (T3); `snapStream` (T4); `useSessionMessages` (T5); `Message` (T6); `Composer` (T7); `StreamingText`(`@/lib/streaming`); `Sheet`(`@/shared/ui/sheet`); `SnapSession` (T1); `react-router-dom`(useParams/useLocation/useNavigate).
- Produces: `useSnapChat(sessionId)``{ send, stop }`; `<ChatHeader session onBack onOpenDetail />`; `<NavRail messages containerRef />`; `<DetailPanel session open onOpenChange />`; `<ClipBanner />`; 완성된 `SessionChatPage`.
- [ ] **Step 1: 전송 오케스트레이션 훅 구현**
Create `src/features/snap/hooks/useSnapChat.ts`:
```ts
import { useCallback } from "react"
import { toast } from "sonner"
import { useSnapChatStore } from "../store/snapChatStore"
import { snapStream } from "../api/snap.stream"
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
export function useSnapChat(sessionId: string) {
const send = useCallback(
async (text: string) => {
const store = useSnapChatStore.getState()
store.currentController?.abort()
store.addUserMessage(text)
store.startAssistantMessage()
store.setStreaming(true)
const ctrl = new AbortController()
store.setController(ctrl)
try {
await snapStream(
{ sessionId, content: text },
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
onDone: () => {},
onError: (e) => toast.error(`스트림 오류: ${e.message}`),
},
{ signal: ctrl.signal },
)
} finally {
const s = useSnapChatStore.getState()
if (s.currentController === ctrl) {
s.setController(null)
s.setStreaming(false)
}
}
},
[sessionId],
)
const stop = useCallback(() => useSnapChatStore.getState().stop(), [])
return { send, stop }
}
```
- [ ] **Step 2: ChatHeader 구현**
Create `src/features/snap/components/ChatHeader.tsx`:
```tsx
import { ChevronLeft, Diamond } from "lucide-react"
import type { SnapSession } from "../contract/types"
interface Props {
session: SnapSession
onBack: () => void
onOpenDetail: () => void
}
export function ChatHeader({ session, onBack, onOpenDetail }: Props) {
const title = session.titleLlm ?? session.title ?? "새 대화 세션"
return (
<div className="flex flex-none items-center gap-2 border-b border-border bg-card px-3 py-2.5">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 font-mono text-[11px] text-muted-foreground hover:border-ring hover:text-foreground"
>
<ChevronLeft className="size-3" />
목록
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-base font-semibold italic">{title}</div>
<div className="font-mono text-[9.5px] uppercase tracking-wide text-muted-foreground">
{(session.tag ?? "SESSION").toUpperCase()} · 대화 세션
</div>
</div>
<button
type="button"
onClick={onOpenDetail}
title="세션 상세"
className="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 font-mono text-[10px] text-muted-foreground hover:border-ring hover:text-foreground"
>
<Diamond className="size-3" />
{session.tokens ?? "0"}
</button>
</div>
)
}
```
- [ ] **Step 3: NavRail 구현**
Create `src/features/snap/components/NavRail.tsx`:
```tsx
import { useMemo } from "react"
import { Code2 } from "lucide-react"
import type { SnapChatMessage } from "../store/snapChatStore"
interface Props {
messages: SnapChatMessage[]
/** 스트림 컨테이너 ref — 코드블럭으로 스크롤 점프. */
containerRef: React.RefObject<HTMLDivElement>
}
interface Block {
lang: string
index: number
}
/** assistant 메시지 markdown 에서 코드펜스를 훑어 블록 목록을 만든다(렌더된 순서와 동일). */
function scanBlocks(messages: SnapChatMessage[]): Block[] {
const blocks: Block[] = []
let idx = 0
for (const m of messages) {
if (m.role !== "assistant") continue
const re = /```(\w+)?/g
let match: RegExpExecArray | null
let open = true
while ((match = re.exec(m.content))) {
if (open) blocks.push({ lang: (match[1] ?? "code").toUpperCase(), index: idx++ })
open = !open
}
}
return blocks
}
export function NavRail({ messages, containerRef }: Props) {
const blocks = useMemo(() => scanBlocks(messages), [messages])
const jump = (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(`[data-code-block="${index}"]`)
el?.scrollIntoView({ behavior: "smooth", block: "center" })
}
return (
<aside className="hidden w-52 flex-none flex-col border-r border-border bg-card md:flex">
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2.5">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Source Nav</span>
</div>
{blocks.length === 0 ? (
<div className="p-4 font-mono text-[10px] leading-relaxed text-muted-foreground">
코드블럭 없음
<br />
대화를 시작하면
<br />
여기에 표시됨.
</div>
) : (
<div className="flex flex-col gap-0.5 overflow-y-auto p-1.5">
{blocks.map((b) => (
<button
key={b.index}
type="button"
onClick={() => jump(b.index)}
className="flex items-center gap-1.5 rounded px-2 py-1.5 text-left font-mono text-[11px] hover:bg-accent"
>
<Code2 className="size-3 flex-none text-muted-foreground" />
<span className="truncate">{b.lang}</span>
</button>
))}
</div>
)}
</aside>
)
}
```
Note: `Message`/`CodeBlock` 는 코드블럭에 `data-code-block` 인덱스를 붙여야 점프가 동작한다. T6 `CodeBlock``index` prop 을 이미 받는다 → `Message` 에서 각 코드블럭에 순번을 넘기도록 아래 Step 4 에서 카운터를 건다.
- [ ] **Step 4: Message 에 전역 코드블럭 인덱스 부여**
Modify `src/features/snap/components/Message.tsx` — assistant 코드 렌더가 세션 내 누적 순번을 쓰도록, 모듈 스코프 대신 렌더별 카운터를 넘긴다. `Message``codeIndexBase` prop 추가:
```tsx
interface Props {
role: SnapRole
content: string
/** 이 메시지 첫 코드블럭의 전역 시작 인덱스 (NavRail 점프 매칭용). */
codeIndexBase?: number
}
```
그리고 `code` 렌더러에서 로컬 카운터로 base 를 더한다:
```tsx
export function Message({ role, content, codeIndexBase = 0 }: Props) {
const isUser = role === "user"
let local = 0
// ...동일...
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className ?? "")
if (match) {
const index = codeIndexBase + local++
return <CodeBlock code={String(children).replace(/\n$/, "")} lang={match[1]} index={index} />
}
// ...inline 동일...
},
```
SessionChatPage(Step 6) 가 메시지 순회하며 이전 assistant 들의 코드블럭 수를 누적해 `codeIndexBase` 를 넘긴다.
- [ ] **Step 5: DetailPanel + ClipBanner 구현**
Create `src/features/snap/components/DetailPanel.tsx`:
```tsx
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/shared/ui/sheet"
import type { SnapSession } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
session: SnapSession
open: boolean
onOpenChange: (v: boolean) => void
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex items-center justify-between border-b border-border py-2.5">
<span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">{k}</span>
<span className="font-mono text-xs font-semibold">{v}</span>
</div>
)
}
export function DetailPanel({ session, open, onOpenChange }: Props) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-80">
<SheetHeader>
<SheetTitle className="font-serif italic">{session.titleLlm ?? session.title ?? "세션"}</SheetTitle>
</SheetHeader>
<div className="mt-4 px-1">
<Row k="태그" v={session.tag ?? "—"} />
<Row k="누적 토큰" v={session.tokens ?? "—"} />
<Row k="상태" v={session.isGenerating ? "● 생성 중" : "대기"} />
<Row k="수정" v={formatRelativeKo(session.updatedAt)} />
</div>
</SheetContent>
</Sheet>
)
}
```
Create `src/features/snap/components/ClipBanner.tsx`:
```tsx
import { useEffect, useState } from "react"
import { Clipboard } from "lucide-react"
import { toast } from "sonner"
/** 채팅 진입 후 타이머로 등장하는 클립보드 감지 배너(비주얼 데모). 실시간 OS 감지는 2차. */
export function ClipBanner() {
const [show, setShow] = useState(false)
useEffect(() => {
const t = setTimeout(() => setShow(true), 2600)
return () => clearTimeout(t)
}, [])
if (!show) return null
return (
<div className="mx-3 mt-3 flex items-center gap-2.5 rounded-md border border-amber-400/50 bg-amber-50 px-3 py-2.5 text-sm dark:bg-amber-950/30">
<Clipboard className="size-4 flex-none text-amber-600" />
<span className="flex-1 leading-snug">
클립보드에서 <b>ABAP 코드</b> 감지됐어. 세션에 붙여넣을까?
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => {
toast.success("클립보드 코드 붙여넣음")
setShow(false)
}}
className="rounded-md bg-primary px-2.5 py-1 text-[11px] font-semibold text-primary-foreground"
>
붙여넣기
</button>
<button
type="button"
onClick={() => setShow(false)}
className="rounded-md border border-border px-2.5 py-1 text-[11px] font-semibold text-muted-foreground"
>
무시
</button>
</div>
</div>
)
}
```
- [ ] **Step 6: SessionChatPage 구현**
Replace `src/features/snap/pages/SessionChatPage.tsx`:
```tsx
import { useEffect, useRef } from "react"
import { useParams, useNavigate, useLocation } from "react-router-dom"
import { useShallow } from "zustand/react/shallow"
import { PATHS } from "@/config/routes"
import { StreamingText } from "@/lib/streaming"
import { useSessionMessages } from "../api/snap.api"
import { useSnapChatStore } from "../store/snapChatStore"
import { useSnapChat } from "../hooks/useSnapChat"
import type { SnapSession } from "../contract/types"
import { ChatHeader } from "../components/ChatHeader"
import { NavRail } from "../components/NavRail"
import { DetailPanel } from "../components/DetailPanel"
import { ClipBanner } from "../components/ClipBanner"
import { Message } from "../components/Message"
import { Composer } from "../components/Composer"
import { useState } from "react"
/** markdown 안 코드펜스 개수 — NavRail 인덱스 누적용. */
function countFences(content: string): number {
const m = content.match(/```/g)
return m ? Math.floor(m.length / 2) : 0
}
export default function SessionChatPage() {
const { id = "" } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { data: detail } = useSessionMessages(id)
const { send, stop } = useSnapChat(id)
const [detailOpen, setDetailOpen] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const { messages, isStreaming } = useSnapChatStore(
useShallow((s) => ({ messages: s.messages, isStreaming: s.isStreaming })),
)
// 세션 진입: 과거대화 seed. NewChatPage 에서 넘어온 firstMessage 있으면 seed 없이 바로 전송.
const firstMessage = (location.state as { firstMessage?: string } | null)?.firstMessage
useEffect(() => {
const store = useSnapChatStore.getState()
if (firstMessage) {
store.seed(id, [])
void send(firstMessage)
// state 소비 후 제거(새로고침 시 재전송 방지)
navigate(`/snap/s/${id}`, { replace: true, state: null })
} else if (detail) {
store.seed(id, detail.messages)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, detail])
// 새 메시지 오면 하단 고정
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [messages])
const session: SnapSession = detail ?? {
id,
title: "새 대화 세션",
titleLlm: null,
isGenerating: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
// 각 메시지의 코드블럭 시작 인덱스 누적(assistant 만 카운트)
let codeAcc = 0
return (
<div className="flex h-full flex-col">
<ChatHeader session={session} onBack={() => navigate(PATHS.SNAP)} onOpenDetail={() => setDetailOpen(true)} />
<ClipBanner />
<div className="flex min-h-0 flex-1">
<NavRail messages={messages} containerRef={scrollRef} />
<div ref={scrollRef} className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.map((m, i) => {
const base = codeAcc
if (m.role === "assistant") codeAcc += countFences(m.content)
const isLiveLast = isStreaming && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
return (
<div key={m.id} className="flex flex-col items-start gap-1">
<span className="font-mono text-[9.5px] uppercase tracking-widest text-muted-foreground">SNAP MATE</span>
<div className="max-w-[85%] rounded-lg border border-border bg-muted px-3 py-2 text-sm leading-relaxed">
<StreamingText text={m.content} isStreaming />
</div>
</div>
)
}
return <Message key={m.id} role={m.role} content={m.content} codeIndexBase={base} />
})}
</div>
</div>
</div>
<Composer onSend={send} busy={isStreaming} onStop={stop} placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…" />
<DetailPanel session={session} open={detailOpen} onOpenChange={setDetailOpen} />
</div>
)
}
```
- [ ] **Step 7: 채팅 동작 확인 (브라우저)**
Run: `npm run dev` → http://localhost:15173/snap/s/cds-view
Expected:
- 과거대화(assistant/user) 렌더, CDS 코드블럭 표시 + 복사 버튼 동작(toast)
- 좌측 NavRail 에 코드블럭(예: ABAP) 뜨고 클릭 시 해당 블럭으로 스크롤
- 입력 후 전송 → user 버블 + assistant 타자기 스트리밍 → 완료 후 markdown/코드블럭으로 표시, 중단 버튼 동작
- 헤더 배지 클릭 → DetailPanel Sheet 열림/닫힘
- 진입 2.6초 후 클립보드 배너 등장, 무시/붙여넣기 동작
- `/snap/s/review-abap`(코드 없는 세션) → NavRail "코드블럭 없음" 표시
- [ ] **Step 8: lint + build 게이트**
Run: `npm run lint && npm run build`
Expected: 성공.
---
### Task 11: 새 대화 vertical (히어로 + 추천 → 세션 생성)
**Files:**
- Create: `src/features/snap/components/SuggestCard.tsx`
- Create: `src/features/snap/components/Hero.tsx`
- Modify: `src/features/snap/pages/NewChatPage.tsx` (stub → 실제)
**Interfaces:**
- Consumes: `useCreateSession` (T5); `Composer` (T7); `react-router-dom`(useNavigate).
- Produces: `<SuggestCard tag title desc onPick />`; `<Hero onPick />`; 완성된 `NewChatPage`.
- [ ] **Step 1: SuggestCard 구현**
Create `src/features/snap/components/SuggestCard.tsx`:
```tsx
interface Props {
tag: string
title: string
desc: string
prompt: string
onPick: (prompt: string) => void
}
export function SuggestCard({ tag, title, desc, prompt, onPick }: Props) {
return (
<button
type="button"
onClick={() => onPick(prompt)}
className="flex flex-col gap-1 rounded-lg border border-border bg-card p-3.5 text-left transition-colors hover:border-ring"
>
<span className="font-mono text-[9.5px] uppercase tracking-wide text-muted-foreground">{tag}</span>
<span className="text-sm font-semibold">{title}</span>
<span className="text-xs leading-snug text-muted-foreground">{desc}</span>
</button>
)
}
```
- [ ] **Step 2: Hero 구현**
Create `src/features/snap/components/Hero.tsx`:
```tsx
import { SuggestCard } from "./SuggestCard"
interface Props {
onPick: (prompt: string) => void
}
const SUGGESTS = [
{ tag: "CDS Views", title: "CDS 뷰 생성", desc: "VBAK/VBAP 헤더·아이템 조인 뷰 골격", prompt: "VBAK/VBAP를 조인한 CDS 뷰 골격을 만들어줘." },
{ tag: "ABAP", title: "코드 리뷰", desc: "SELECT 루프·내부 테이블 성능 점검", prompt: "이 ABAP 리포트의 SELECT 루프 성능을 리뷰해줘." },
{ tag: "Error", title: "Short Dump 분석", desc: "ST22 런타임 오류 원인 진단", prompt: "CX_SY_OPEN_SQL_DB Short Dump 원인을 분석해줘." },
{ tag: "HANA", title: "AMDP 튜닝", desc: "스칼라 UDF 제거·셋 기반 재작성", prompt: "AMDP 프로시저를 셋 기반 SQLScript로 튜닝해줘." },
]
export function Hero({ onPick }: Props) {
return (
<section className="flex flex-col items-center px-6 py-10 text-center">
<div className="mb-4 grid size-14 place-items-center rounded-xl bg-primary font-serif text-2xl font-bold italic text-primary-foreground">
S
</div>
<span className="mb-3 inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-500" />
New Session
</span>
<h1 className="mb-2 font-serif text-2xl font-semibold italic">무엇을 도와드릴까요?</h1>
<p className="mb-6 max-w-sm text-sm leading-relaxed text-muted-foreground">
ABAP · CDS · HANA · 에러 분석까지 코드나 로그를 붙여넣거나 아래에서 시작해봐.
</p>
<div className="grid w-full max-w-lg grid-cols-1 gap-2.5 sm:grid-cols-2">
{SUGGESTS.map((s) => (
<SuggestCard key={s.title} {...s} onPick={onPick} />
))}
</div>
</section>
)
}
```
- [ ] **Step 3: NewChatPage 구현**
Replace `src/features/snap/pages/NewChatPage.tsx`:
```tsx
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { useCreateSession } from "../api/snap.api"
import { Hero } from "../components/Hero"
import { Composer } from "../components/Composer"
export default function NewChatPage() {
const navigate = useNavigate()
const createSession = useCreateSession()
const [pending, setPending] = useState<string | null>(null)
// 첫 전송: 세션 생성 → 채팅 페이지로 이동하며 firstMessage 전달(거기서 스트림).
const start = async (text: string) => {
if (pending) return
setPending(text)
const session = await createSession.mutateAsync()
navigate(`/snap/s/${session.id}`, { state: { firstMessage: text } })
}
// 추천 카드 클릭 = 그 프롬프트로 바로 시작.
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl">
<Hero onPick={start} />
</div>
</div>
<Composer onSend={start} busy={!!pending} placeholder="새 대화를 시작하세요 — 질문을 입력하거나 코드를 붙여넣어봐…" />
</div>
)
}
```
- [ ] **Step 4: 새 대화 동작 확인 (브라우저)**
Run: `npm run dev` → http://localhost:15173/snap/new
Expected:
- 히어로 + 추천카드 4개 렌더
- 추천카드 클릭 → 세션 생성 후 `/snap/s/:id` 이동, 그 프롬프트가 전송되어 스트리밍 시작
- 하단 입력창에 직접 입력 후 전송 → 동일하게 세션 생성 + 이동 + 스트리밍
- 이동한 채팅 페이지 새로고침 시 firstMessage 재전송 안 됨(state 소비됨)
- [ ] **Step 5: 전체 게이트**
Run: `npm run lint && npm run build && npm run test`
Expected: 모두 성공.
---
## 최종 검증 (Wave 4 완료 후)
- [ ] `npm run lint` 깨끗
- [ ] `npm run build` 성공
- [ ] `npm run test` 통과 (T1~T5 유닛)
- [ ] 브라우저 시나리오 (spec §10):
1. `/snap` 목록+검색+클릭 진입
2. `/snap/s/cds-view` 과거대화+코드복사
3. 전송 → 타자기 스트리밍
4. `/snap/new` 히어로+추천 → 세션 진입
5. NavRail 점프 · DetailPanel 열림 · ClipBanner 등장
6. 기존 `/`·`/chat`·`/me` 등 대시보드 라우트 안 깨짐
- [ ] mock 격리 확인: `features/snap/mock/` + `snap.api.ts`/`snap.stream.ts``// TODO(backend)` 주석만 손대면 real 전환됨
## 2차 (이번 범위 밖)
real 백엔드 부착(USE_MOCK=false, axios 배선, 인증) · 키보드 단축키 세트(↑↓/Ctrl+J/Alt화살표/Tab순환/Ctrl+Shift+C) · 실시간 클립보드 OS 읽기 · syntax highlight 강화 · rename/delete/search 엔드포인트 · clarify(라우팅 후보) UI