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

65 KiB

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 devhttp://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:

// 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:

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:

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.tsPATHS 객체 안 SETTINGS_THEME 줄 아래에 추가:

  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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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 선언들 아래)에 추가:

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 추가:

import { SnapLayout } from "@/features/snap/components/SnapLayout"

(b) <ProtectedRoute />children 배열에서, 기존 { element: <DashboardLayout /> , ... } 앞에 SnapLayout 그룹을 형제로 추가:

  {
    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 devhttp://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:

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:

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:

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 devhttp://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:

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:

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:

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 CodeBlockindex prop 을 이미 받는다 → Message 에서 각 코드블럭에 순번을 넘기도록 아래 Step 4 에서 카운터를 건다.

  • Step 4: Message 에 전역 코드블럭 인덱스 부여

Modify src/features/snap/components/Message.tsx — assistant 코드 렌더가 세션 내 누적 순번을 쓰도록, 모듈 스코프 대신 렌더별 카운터를 넘긴다. MessagecodeIndexBase prop 추가:

interface Props {
  role: SnapRole
  content: string
  /** 이 메시지 첫 코드블럭의 전역 시작 인덱스 (NavRail 점프 매칭용). */
  codeIndexBase?: number
}

그리고 code 렌더러에서 로컬 카운터로 base 를 더한다:

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:

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:

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:

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 devhttp://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:

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:

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:

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 devhttp://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