1757 lines
65 KiB
Markdown
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
|