Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,879 @@
# Snap 백엔드 연결 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:** snap 목업(sessions/conversations/stream)을 걷어내고 base-backend(000)의 세션 기반 chat 계약(`/api/v1/chat`)에 real 연결한다 — 세션 목록/조회/생성 + SSE 토큰 스트리밍 + LLM 자동제목, 그리고 .NET 웹뷰용 Bearer 토큰 주입 seam.
**Architecture:** 기존 seam 에 real 구현을 끼운다. `snap.api.ts` 3함수는 mock 반환 → `apiGet/apiList/apiPost` 호출로 교체, `snap.stream.ts``mockStream` 제거하고 `streamLLM("/chat/stream")` 로 직결. `streamLLM``title` 이벤트 파싱 추가, 인증은 `getAccessToken()` provider 한 곳을 만들어 `client.ts`(axios 요청 인터셉터)와 `sse.ts`(헤더)가 참조 — 토큰 있으면 `Authorization: Bearer`, 없으면(=오늘) 쿠키 폴백. UI/store/컴포넌트는 안 건드린다.
**Tech Stack:** React 18 + TS + Vite + Zustand + TanStack Query + axios + `@microsoft/fetch-event-source` + vitest + @testing-library/react.
## Global Constraints
- 작업 디렉토리: `D:\project\021.code-assistant-v2\2_frontend` (모든 경로 이 기준 상대).
- git: 브랜치 `feat/snap-backend-connect` (이미 생성됨) → **태스크마다 자기 파일만 add 후 커밋, push 금지**.
- 게이트: 자기 파일 `npx eslint <파일>` 클린 + 해당 테스트 통과. Wave 경계에서 `npm run build`.
- baseline lint 주의: 프로젝트 전체 `npm run lint` 에 이번 작업 무관 기존 에러/경고 있음 — 스코프는 항상 `npx eslint <자기 파일>`.
- 응답·주석 톤: 한글 반말 (CLAUDE.md 0번). 백엔드 계약은 camelCase(CamelModel 정렬).
- import 별칭: `@/``src/`.
- dev 서버: `npm run dev` → http://localhost:15173. 백엔드: base-backend 로컬 8001, Vite proxy `/api`→8001.
- 테스트: vitest. 네트워크/스트림은 mock (실제 백엔드 호출 금지). 실제 백엔드 연동은 §검증에서 육안.
- **건드리지 말 것**: `features/chat/*`, `chatStore`, `DashboardLayout`, snap 의 store/components/pages. 이번 수정 파일은 아래 8개 태스크에 명시된 것만.
---
## 파일 구조 (수정/신규/삭제)
```
신규:
src/lib/auth/tokenProvider.ts # accessToken 단일 소스 (get/set)
src/lib/auth/tokenProvider.test.ts
src/lib/streaming/streamLLM.test.ts # title 이벤트 테스트 (신규)
src/features/snap/hooks/useSnapChat.test.tsx
수정:
src/lib/streaming/streamLLM.ts # title 이벤트 + onTitle 핸들러
src/lib/api/client.ts # 요청 인터셉터 Bearer 주입
src/lib/api/client.test.ts # Bearer 테스트 추가
src/lib/streaming/sse.ts # Authorization 헤더 (토큰 있을 때)
src/lib/streaming/sse.test.ts # Bearer 테스트 추가
src/features/snap/api/snap.api.ts # mock → apiGet/apiList/apiPost
src/features/snap/api/snap.api.test.tsx # mock client 로 재작성
src/features/snap/api/snap.stream.ts # USE_MOCK 제거, streamLLM 직결 + onTitle
src/features/snap/api/snap.stream.test.ts# streamLLM mock 으로 재작성
src/features/snap/hooks/useSnapChat.ts # title 캐시 패치 + 409 처리 + 중복 가드
삭제:
src/features/snap/mock/sessions.ts
src/features/snap/mock/conversations.ts
src/features/snap/mock/stream.ts
src/features/snap/mock/mock.test.ts
```
---
## Wave 실행 맵
| Wave | Task | 병렬성 | 파일(disjoint) | 의존 |
|---|---|---|---|---|
| W1 | T1 tokenProvider | 병렬 | `lib/auth/tokenProvider.ts(+test)` | — |
| W1 | T2 streamLLM title | 병렬 | `lib/streaming/streamLLM.ts(+test)` | — |
| W1 | T3 snap.api real | 병렬 | `features/snap/api/snap.api.ts(+test)` | — |
| W2 | T4 client Bearer | 병렬 | `lib/api/client.ts(+test)` | T1 |
| W2 | T5 sse Bearer | 병렬 | `lib/streaming/sse.ts(+test)` | T1 |
| W2 | T6 snap.stream real+title | 병렬 | `features/snap/api/snap.stream.ts(+test)` | T2 |
| W3 | T7 useSnapChat title+409 | 병렬 | `features/snap/hooks/useSnapChat.ts(+test)` | T3,T6 |
| W3 | T8 목업 제거 | 병렬 | `mock/*.ts` 삭제 | T3,T6 |
같은 wave = 파일 disjoint + 시그니처 import 없음. W1 셋은 서로 안 엮임. W2 셋은 각각 W1 산출(getAccessToken/onTitle)만 소비하고 서로 안 엮임. W3 둘은 파일 disjoint.
---
## Task T1: tokenProvider (accessToken 단일 소스)
**Files:**
- Create: `src/lib/auth/tokenProvider.ts`
- Test: `src/lib/auth/tokenProvider.test.ts`
**Interfaces:**
- Consumes: 없음
- Produces: `getAccessToken(): string | null`, `setAccessToken(token: string | null): void`
- [ ] **Step 1: 실패 테스트 작성**`src/lib/auth/tokenProvider.test.ts`
```ts
import { describe, it, expect, afterEach } from "vitest"
import { getAccessToken, setAccessToken } from "./tokenProvider"
afterEach(() => setAccessToken(null))
describe("tokenProvider", () => {
it("기본값은 null (쿠키 모드)", () => {
expect(getAccessToken()).toBeNull()
})
it("set 하면 그 토큰을 돌려준다", () => {
setAccessToken("abc")
expect(getAccessToken()).toBe("abc")
})
it("null 로 다시 초기화 가능", () => {
setAccessToken("abc")
setAccessToken(null)
expect(getAccessToken()).toBeNull()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/auth/tokenProvider.test.ts`
Expected: FAIL — `Failed to resolve import "./tokenProvider"`
- [ ] **Step 3: 구현**`src/lib/auth/tokenProvider.ts`
```ts
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
let accessToken: string | null = null
export function getAccessToken(): string | null {
return accessToken
}
export function setAccessToken(token: string | null): void {
accessToken = token
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/auth/tokenProvider.test.ts`
Expected: PASS (3 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
git add src/lib/auth/tokenProvider.ts src/lib/auth/tokenProvider.test.ts
git commit -m "feat(auth): add accessToken provider seam for .NET webview"
```
---
## Task T2: streamLLM 에 title 이벤트 추가
**Files:**
- Modify: `src/lib/streaming/streamLLM.ts`
- Test: `src/lib/streaming/streamLLM.test.ts` (신규)
**Interfaces:**
- Consumes: 없음 (기존 `streamSSE`)
- Produces: `LLMStreamHandlers.onTitle?: (title: string) => void` — T6 이 소비
- [ ] **Step 1: 실패 테스트 작성**`src/lib/streaming/streamLLM.test.ts`
```ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { streamLLM } from "./streamLLM"
import * as sse from "./sse"
vi.mock("./sse", () => ({ streamSSE: vi.fn() }))
describe("streamLLM title 이벤트", () => {
beforeEach(() => vi.clearAllMocks())
it("title 이벤트를 onTitle 로 전달한다", async () => {
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
opts.onEvent({ event: "title", data: JSON.stringify({ title: "판매문서 조인" }) })
})
const onTitle = vi.fn()
await streamLLM({
path: "/chat/stream",
body: {},
handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle },
})
expect(onTitle).toHaveBeenCalledWith("판매문서 조인")
})
it("malformed title 은 조용히 무시(throw 안 함)", async () => {
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
streamSSE.mockImplementation(async (opts: { onEvent: (e: { event: string; data: string }) => void }) => {
opts.onEvent({ event: "title", data: "not-json" })
})
const onTitle = vi.fn()
await expect(
streamLLM({ path: "/x", body: {}, handlers: { onToken: vi.fn(), onDone: vi.fn(), onTitle } }),
).resolves.toBeUndefined()
expect(onTitle).not.toHaveBeenCalled()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/streaming/streamLLM.test.ts`
Expected: FAIL — `onTitle` 이 호출 안 됨 (title 분기 없음)
- [ ] **Step 3: 구현**`src/lib/streaming/streamLLM.ts`
3-1. `LLMStreamHandlers` 인터페이스에 `onTitle` 추가 (기존 `onToken` 위, 라인 61 근처):
```ts
/** `title` 이벤트 — 첫 메시지 후 LLM 이 지은 세션 제목 (선택) */
onTitle?: (title: string) => void
/** `token` 이벤트 — 토큰 조각 누적해서 표시 */
onToken: (delta: string) => void
```
3-2. `ErrorPayload` 인터페이스 아래(라인 87 근처)에 payload 타입 추가:
```ts
interface TitlePayload {
title: string
}
```
3-3. `onEvent` 스위치에서 `result` 분기 다음에 `title` 분기 추가 (라인 126 `else if (e.event === "clarify")` 앞):
```ts
} else if (e.event === "title") {
try {
const payload = JSON.parse(e.data) as TitlePayload
if (typeof payload.title === "string") handlers.onTitle?.(payload.title)
} catch {
// 제목 갱신은 부가정보 — 실패해도 토큰 흐름엔 영향 없음
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/streaming/streamLLM.test.ts`
Expected: PASS (2 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
git add src/lib/streaming/streamLLM.ts src/lib/streaming/streamLLM.test.ts
git commit -m "feat(streaming): parse SSE title event into onTitle handler"
```
---
## Task T3: snap.api 목업 → real HTTP
**Files:**
- Modify: `src/features/snap/api/snap.api.ts`
- Test: `src/features/snap/api/snap.api.test.tsx` (재작성)
**Interfaces:**
- Consumes: `apiGet<T>(path)`, `apiPost<T>(path, body?)`, `apiList<T>(path)` from `@/lib/api/client`
- Produces: `useSessionList()``SnapSession[]` (queryKey `["snap","sessions"]`), `useSessionMessages(id)``SnapSessionDetail` (queryKey `["snap","session",id]`), `useCreateSession()` → mutation `SnapSession`
- [ ] **Step 1: 테스트 재작성 (실패)**`src/features/snap/api/snap.api.test.tsx` 전체 교체
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useSessionList, useSessionMessages } from "./snap.api"
import * as client from "@/lib/api/client"
vi.mock("@/lib/api/client")
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
const SESSION = {
id: "s1",
title: "t",
titleLlm: null,
isGenerating: false,
createdAt: "2026-07-18T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
}
describe("snap.api (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("useSessionList 는 GET /chat/sessions 의 items 를 반환", async () => {
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
const { result } = renderHook(() => useSessionList(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions")
expect(result.current.data![0].id).toBe("s1")
})
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
expect(result.current.data!.messages).toEqual([])
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/features/snap/api/snap.api.test.tsx`
Expected: FAIL — 현재 `snap.api.ts` 가 mock 반환이라 `client.apiList` 미호출
- [ ] **Step 3: 구현**`src/features/snap/api/snap.api.ts` 전체 교체
```ts
import { useQuery, useMutation } from "@tanstack/react-query"
import { apiGet, apiList, apiPost } from "@/lib/api/client"
import type { SnapSession, SnapSessionDetail } from "../contract/types"
export function useSessionList() {
return useQuery({
queryKey: ["snap", "sessions"],
queryFn: async (): Promise<SnapSession[]> => {
const { items } = await apiList<SnapSession>("/chat/sessions")
return items
},
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: (): Promise<SnapSessionDetail> =>
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
})
}
export function useCreateSession() {
return useMutation({
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
})
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/features/snap/api/snap.api.test.tsx`
Expected: PASS (2 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
git add src/features/snap/api/snap.api.ts src/features/snap/api/snap.api.test.tsx
git commit -m "feat(snap): wire session list/detail/create to real backend"
```
---
## Task T4: client.ts 요청 인터셉터 Bearer 주입
**Files:**
- Modify: `src/lib/api/client.ts`
- Test: `src/lib/api/client.test.ts` (describe 블록 추가)
**Interfaces:**
- Consumes: `getAccessToken()` from `@/lib/auth/tokenProvider` (T1)
- Produces: `apiClient` 가 토큰 있으면 `Authorization: Bearer <t>` 헤더 부착
- [ ] **Step 1: 실패 테스트 추가**`src/lib/api/client.test.ts` 파일 끝에 아래 추가. 파일 상단 import 에 `setAccessToken`, `apiClient`, `afterEach` 가 없으면 추가.
```ts
import { afterEach } from "vitest"
import { apiClient } from "./client"
import { setAccessToken } from "@/lib/auth/tokenProvider"
describe("apiClient Bearer 주입", () => {
afterEach(() => {
setAccessToken(null)
delete apiClient.defaults.adapter
})
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
setAccessToken("tok123")
let seen: unknown
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
}
await apiClient.get("/ping")
expect(seen).toBe("Bearer tok123")
})
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
setAccessToken(null)
let seen: unknown = "sentinel"
apiClient.defaults.adapter = async (config) => {
seen = config.headers.Authorization
return { data: { success: true, data: null }, status: 200, statusText: "OK", headers: {}, config } as never
}
await apiClient.get("/ping")
expect(seen).toBeUndefined()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/api/client.test.ts`
Expected: FAIL — 토큰 넣어도 `Authorization` 이 undefined (인터셉터 없음)
- [ ] **Step 3: 구현**`src/lib/api/client.ts`
3-1. import 추가 (파일 상단 import 그룹):
```ts
import { getAccessToken } from "@/lib/auth/tokenProvider"
```
3-2. `apiClient` 생성 직후(라인 26 `})` 다음), 응답 인터셉터 위에 요청 인터셉터 추가:
```ts
// .NET 웹뷰 호스트가 토큰을 주입하면 Bearer 로, 아니면(=오늘) 쿠키로.
apiClient.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/api/client.test.ts`
Expected: PASS (기존 테스트 + 새 2개)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/api/client.ts src/lib/api/client.test.ts
git add src/lib/api/client.ts src/lib/api/client.test.ts
git commit -m "feat(api): inject Bearer token from provider when present"
```
---
## Task T5: sse.ts Authorization 헤더
**Files:**
- Modify: `src/lib/streaming/sse.ts`
- Test: `src/lib/streaming/sse.test.ts` (테스트 추가)
**Interfaces:**
- Consumes: `getAccessToken()` from `@/lib/auth/tokenProvider` (T1)
- Produces: SSE 요청이 토큰 있으면 `Authorization: Bearer` 헤더 포함
- [ ] **Step 1: 실패 테스트 추가**`src/lib/streaming/sse.test.ts`
1-1. 상단 import 에 추가:
```ts
import { afterEach } from "vitest"
import { setAccessToken } from "@/lib/auth/tokenProvider"
```
1-2. `describe("streamSSE", ...)` 안, 기존 `beforeEach` 아래에 추가:
```ts
afterEach(() => setAccessToken(null))
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
setAccessToken("tok")
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
fetchEventSource.mockResolvedValue(undefined)
await streamSSE({ path: "/chat/stream", body: { message: "hi" }, onEvent: vi.fn() })
const [, opts] = fetchEventSource.mock.calls[0]
expect(opts.headers.Authorization).toBe("Bearer tok")
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/lib/streaming/sse.test.ts`
Expected: FAIL — 새 테스트에서 `Authorization` 이 undefined
- [ ] **Step 3: 구현**`src/lib/streaming/sse.ts`
3-1. import 추가 (라인 2 `env` import 아래):
```ts
import { getAccessToken } from "@/lib/auth/tokenProvider"
```
3-2. `open()` 함수 시작부에서 헤더를 토큰 유무로 구성 (기존 `await fetchEventSource(...)` 의 인라인 `headers` 를 교체):
```ts
async function open(opts: StreamSSEOptions, retried: boolean): Promise<void> {
const token = getAccessToken()
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (token) headers.Authorization = `Bearer ${token}`
await fetchEventSource(`${env.apiBaseUrl}${opts.path}`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(opts.body),
signal: opts.signal,
openWhenHidden: true,
onopen: async (res) => {
if (res.ok) return
if (res.status === 401 && !retried) {
throw new RetryableUnauthorized()
}
throw new Error(`SSE open failed: ${res.status}`)
},
onmessage: (msg) => {
opts.onEvent({ event: msg.event || "message", data: msg.data, id: msg.id })
},
onerror: (err) => {
if (err instanceof RetryableUnauthorized) throw err
// 자동 reconnect 방지: throw하면 종료
opts.onError?.(err)
throw err
},
})
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/lib/streaming/sse.test.ts`
Expected: PASS (기존 3개 + 새 1개). 기존 "쿠키 기반" 테스트의 `not.toHaveProperty("Authorization")` 는 토큰 null 이라 그대로 통과.
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
git add src/lib/streaming/sse.ts src/lib/streaming/sse.test.ts
git commit -m "feat(streaming): add Authorization header to SSE when token present"
```
---
## Task T6: snap.stream USE_MOCK 제거 + streamLLM 직결 + onTitle
**Files:**
- Modify: `src/features/snap/api/snap.stream.ts`
- Test: `src/features/snap/api/snap.stream.test.ts` (재작성)
**Interfaces:**
- Consumes: `streamLLM` (T2 의 `onTitle` 포함)
- Produces: `SnapStreamHandlers { onToken, onDone, onTitle?, onError? }`, `snapStream(req, handlers, opts?)` — T7 이 소비
- [ ] **Step 1: 테스트 재작성 (실패)**`src/features/snap/api/snap.stream.test.ts` 전체 교체
```ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { snapStream } from "./snap.stream"
import * as streaming from "@/lib/streaming"
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
describe("snapStream (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
expect(streamLLM).toHaveBeenCalledTimes(1)
const arg = streamLLM.mock.calls[0][0]
expect(arg.path).toBe("/chat/stream")
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
})
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockImplementation(
async (opts: { handlers: { onToken: (d: string) => void; onTitle?: (t: string) => void; onDone: (p: object) => void } }) => {
opts.handlers.onToken("a")
opts.handlers.onTitle?.("제목")
opts.handlers.onDone({})
},
)
const onToken = vi.fn()
const onTitle = vi.fn()
const onDone = vi.fn()
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
expect(onToken).toHaveBeenCalledWith("a")
expect(onTitle).toHaveBeenCalledWith("제목")
expect(onDone).toHaveBeenCalledTimes(1)
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/features/snap/api/snap.stream.test.ts`
Expected: FAIL — 현재 `USE_MOCK=true``streamLLM` 미호출
- [ ] **Step 3: 구현**`src/features/snap/api/snap.stream.ts` 전체 교체
```ts
import { streamLLM } from "@/lib/streaming"
import type { SnapStreamRequest } from "../contract/types"
export interface SnapStreamHandlers {
onToken: (delta: string) => void
onDone: () => void
onTitle?: (title: string) => void
onError?: (e: Error) => void
}
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
export function snapStream(
req: SnapStreamRequest,
handlers: SnapStreamHandlers,
opts?: { signal?: AbortSignal },
): Promise<void> {
return streamLLM({
path: "/chat/stream",
body: req,
signal: opts?.signal,
handlers: {
onToken: handlers.onToken,
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onError: handlers.onError,
},
})
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/features/snap/api/snap.stream.test.ts`
Expected: PASS (2 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git add src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git commit -m "feat(snap): stream via real /chat/stream and forward title event"
```
---
## Task T7: useSnapChat — title 캐시 패치 + 409 처리 + 중복 가드
**Files:**
- Modify: `src/features/snap/hooks/useSnapChat.ts`
- Test: `src/features/snap/hooks/useSnapChat.test.tsx` (신규)
**Interfaces:**
- Consumes: `snapStream` + `SnapStreamHandlers.onTitle` (T6), queryKeys `["snap","sessions"]`/`["snap","session",id]` (T3), `useSnapChatStore`
- Produces: `useSnapChat(sessionId)``{ send, stop }` (시그니처 불변)
- [ ] **Step 1: 실패 테스트 작성**`src/features/snap/hooks/useSnapChat.test.tsx`
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { useSnapChat } from "./useSnapChat"
import * as stream from "../api/snap.stream"
import { useSnapChatStore } from "../store/snapChatStore"
vi.mock("../api/snap.stream")
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
let qc: QueryClient
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => {
qc = new QueryClient()
useSnapChatStore.getState().reset()
vi.clearAllMocks()
})
describe("useSnapChat", () => {
it("onTitle 이 세션 목록 캐시의 title 을 갱신한다", async () => {
qc.setQueryData(
["snap", "sessions"],
[{ id: "s1", title: null, titleLlm: null, isGenerating: false, createdAt: "", updatedAt: "" }],
)
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onTitle?.("새 제목")
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const list = qc.getQueryData(["snap", "sessions"]) as { id: string; title: string | null }[]
expect(list[0].title).toBe("새 제목")
})
it("409 에러면 '이미 생성 중' 안내 toast", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("이미 생성 중"))
})
it("open 실패로 snapStream 이 reject 해도 send 는 throw 하지 않는다", async () => {
// sse.ts 는 open 실패 시 onError 를 부른 뒤 promise 도 reject 한다(이중 신호).
// send 는 그 rejection 을 삼켜 unhandled rejection 을 막아야 한다.
vi.mocked(stream.snapStream).mockRejectedValue(new Error("SSE open failed: 500"))
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await expect(result.current.send("hi")).resolves.toBeUndefined()
})
it("이미 스트리밍 중이면 두 번째 send 는 무시(중복 가드)", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(stream.snapStream).not.toHaveBeenCalled()
})
})
```
- [ ] **Step 2: 실패 확인**
Run: `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx`
Expected: FAIL — title 미갱신 / 409 분기 없음 / 가드 없음
- [ ] **Step 3: 구현**`src/features/snap/hooks/useSnapChat.ts` 전체 교체
```ts
import { useCallback } from "react"
import { toast } from "sonner"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { useSnapChatStore } from "../store/snapChatStore"
import { snapStream } from "../api/snap.stream"
import type { SnapSession, SnapSessionDetail } from "../contract/types"
// title 이벤트 → react-query 세션 캐시(목록 + 상세)의 title 갱신.
function patchSessionTitle(
queryClient: QueryClient,
sessionId: string,
title: string,
): void {
queryClient.setQueryData<SnapSession[]>(["snap", "sessions"], (prev) =>
prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)),
)
queryClient.setQueryData<SnapSessionDetail>(["snap", "session", sessionId], (prev) =>
prev ? { ...prev, title } : prev,
)
}
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
export function useSnapChat(sessionId: string) {
const queryClient = useQueryClient()
const send = useCallback(
async (text: string) => {
const store = useSnapChatStore.getState()
if (store.isStreaming) return // 이 클라이언트가 이미 생성 중 — 중복 전송 차단
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: () => {},
onTitle: (title) => patchSessionTitle(queryClient, sessionId, title),
onError: (e) => {
if (e.message.includes("409")) {
toast.error("이미 생성 중인 세션이야. 잠깐 기다렸다 다시 보내.")
} else {
toast.error(`스트림 오류: ${e.message}`)
}
},
},
{ signal: ctrl.signal },
)
} catch {
// sse.ts 는 open 실패(409/5xx) 시 onError 를 부른 뒤 promise 도 reject 한다.
// 오류 표시는 위 onError 에서 이미 함 → 여기선 rejection 만 삼켜 unhandled 방지.
// abort 는 라이브러리가 resolve 처리하므로 여기로 안 옴.
} finally {
const s = useSnapChatStore.getState()
if (s.currentController === ctrl) {
s.setController(null)
s.setStreaming(false)
}
}
},
[sessionId, queryClient],
)
const stop = useCallback(() => useSnapChatStore.getState().stop(), [])
return { send, stop }
}
```
- [ ] **Step 4: 통과 확인**
Run: `npx vitest run src/features/snap/hooks/useSnapChat.test.tsx`
Expected: PASS (3 tests)
- [ ] **Step 5: lint + 커밋**
```bash
npx eslint src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git add src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git commit -m "feat(snap): patch session title on title event, handle 409 and dup send"
```
---
## Task T8: 목업 제거
**Files:**
- Delete: `src/features/snap/mock/sessions.ts`, `src/features/snap/mock/conversations.ts`, `src/features/snap/mock/stream.ts`, `src/features/snap/mock/mock.test.ts`
**Interfaces:**
- Consumes: 없음 (T3 에서 `snap.api` 가, T6 에서 `snap.stream` 이 이미 mock import 제거)
- Produces: 없음
- [ ] **Step 1: 잔여 import 확인 (없어야 함)**
Run: `git grep -n "mock/sessions\|mock/conversations\|mock/stream\|MOCK_SESSIONS\|MOCK_CONVERSATIONS\|mockStream" src/`
Expected: 매치 없음(삭제할 mock 파일 자신 제외). 매치 나오면 그 파일부터 정리.
- [ ] **Step 2: 삭제**
```bash
git rm src/features/snap/mock/sessions.ts src/features/snap/mock/conversations.ts src/features/snap/mock/stream.ts src/features/snap/mock/mock.test.ts
```
- [ ] **Step 3: 타입/빌드 확인**
Run: `npx tsc --noEmit`
Expected: 에러 없음 (dangling import 없음)
- [ ] **Step 4: 커밋**
```bash
git commit -m "chore(snap): remove mock session/conversation/stream data"
```
---
## Wave 경계 게이트
각 Wave 끝에서:
- [ ] **W1 후**: `npx vitest run src/lib/auth src/lib/streaming/streamLLM.test.ts src/features/snap/api/snap.api.test.tsx` → 전부 PASS
- [ ] **W2 후**: `npx vitest run src/lib/api/client.test.ts src/lib/streaming/sse.test.ts src/features/snap/api/snap.stream.test.ts` → 전부 PASS
- [ ] **W3 후**: `npm run build` → 성공 + `npx vitest run src/features/snap` → PASS
CLAUDE.md 5·8번: 각 Wave 완료 후 `docs/working/snap-backend-connect-wave-N.md` 떨어뜨리고, 디버깅 사건 있었으면 `docs/troubleshootings/` 기록.
---
## 검증 (수동 · 실제 백엔드)
1. base-backend 로컬 8001 기동 + LLM(NVIDIA NIM) 설정 확인.
2. `npm run dev` → http://localhost:15173 → 로그인.
3. `/snap` → 세션 목록이 **실제 DB 세션**으로 뜸 (mock 아님).
4. `/snap/new` → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후 사이드바 제목이 LLM 이 지은 제목으로 자동 변경 (title 이벤트).
5. 기존 세션 재진입 → 과거 메시지 렌더 정상.
6. 생성 중 같은 세션에 재전송 → 중복 가드 또는 409 toast.
7. Network 탭: `POST /api/v1/chat/stream``text/event-stream` 으로 열리고 쿠키 전송됨.
---
## 미룸 (범위 밖 — 후속 spec)
- rename(PATCH)/delete/search 엔드포인트
- `subagent_start`/`subagent_done` 진행 UI, `usage` 토큰 카운터
- Bearer 모드 refresh(.NET 호스트 위임) 실제 배선 — 호스트 생기면
@@ -0,0 +1,329 @@
# Snap 중단(Stop) UX chat 수준 이식 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox 표기.
**Goal:** snap 중단 UX를 chat 수준으로 — `isRevealing` 2-플래그(네트워크 끝난 뒤 타이핑 tail 동안도 중단 가능), 중단 시 `StoppedNotice`+재시도, 그리고 백엔드 취소 엔드포인트를 best-effort로 호출(미구현이면 조용히 degrade).
**Architecture:** frozen 버블은 **옵션 B**(받은 전체를 `Message`로 유지 → CodeBlock 복사·NavRail 점프 유지) + 아래 `StoppedNotice`. 라이브/타이핑 중 마지막 assistant 버블만 `StreamingText`. `stop()`은 로컬 freeze + `cancelStream(sessionId)`(POST /chat/sessions/{id}/cancel, 실패 삼킴).
**Tech Stack:** React 18 + TS + Zustand + TanStack Query + vitest.
## Global Constraints
- 작업 디렉토리 `D:\project\021.code-assistant-v2\2_frontend`. 브랜치 `feat/snap-stop-ux`. push 금지.
- 게이트: 자기 파일 `npx eslint` + 해당 테스트 통과. 마지막 UI 태스크는 `npm run build` + `npx tsc --noEmit`.
- 한글 반말 주석. 결합 순차(병렬 금지).
- 백엔드 취소 엔드포인트 계약: `POST /api/v1/chat/sessions/{sessionId}/cancel` (auth CurrentUser, is_generating 즉시 clear, 부분 저장 권장). **아직 미구현** — 프론트는 best-effort 호출로 degrade.
## Wave 실행 맵 (전부 순차 — 결합)
| 순서 | Task | 파일 | 의존 |
|---|---|---|---|
| 1 | cancelStream | `api/snap.stream.ts(+test)` | — |
| 2 | store isRevealing | `store/snapChatStore.ts(+test)` | — |
| 3 | useSnapChat stop+retry | `hooks/useSnapChat.ts(+test)` | 1,2 |
| 4 | 페이지/Composer 배선 | `pages/SessionChatPage.tsx`,`components/Composer.tsx` | 2,3 |
---
## Task 1: cancelStream (best-effort 취소 호출)
**Files:** Modify `src/features/snap/api/snap.stream.ts` · Test `src/features/snap/api/snap.stream.test.ts`
**Produces:** `cancelStream(sessionId: string): Promise<void>` — 예외 안 던짐(삼킴).
- [x] **Step 1: 실패 테스트 추가**`snap.stream.test.ts` 에 아래 describe 추가. `@/lib/api/client` 를 mock.
```ts
import * as client from "@/lib/api/client"
import { cancelStream } from "./snap.stream"
vi.mock("@/lib/api/client", () => ({ apiPost: vi.fn() }))
describe("cancelStream", () => {
beforeEach(() => vi.clearAllMocks())
it("POST /chat/sessions/{id}/cancel 를 호출한다", async () => {
vi.mocked(client.apiPost).mockResolvedValue(null)
await cancelStream("s1")
expect(client.apiPost).toHaveBeenCalledWith("/chat/sessions/s1/cancel", {})
})
it("실패해도 throw 하지 않는다(best-effort)", async () => {
vi.mocked(client.apiPost).mockRejectedValue(new Error("404"))
await expect(cancelStream("s1")).resolves.toBeUndefined()
})
})
```
주의: 기존 `snap.stream.test.ts``vi.mock("@/lib/streaming", ...)` 를 이미 씀. 위 `vi.mock("@/lib/api/client", ...)` 를 추가하고, 상단 import 에 `beforeEach` 가 없으면 추가.
- [x] **Step 2: 실패 확인**`npx vitest run src/features/snap/api/snap.stream.test.ts` → FAIL (cancelStream 없음)
- [x] **Step 3: 구현**`snap.stream.ts` 상단 import 에 `apiPost` 추가, 파일 끝에 함수 추가:
```ts
import { apiPost } from "@/lib/api/client"
```
```ts
// 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 —
// 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨.
export async function cancelStream(sessionId: string): Promise<void> {
try {
await apiPost(`/chat/sessions/${sessionId}/cancel`, {})
} catch {
// 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성.
}
}
```
- [x] **Step 4: 통과 확인** — 위 명령 PASS.
- [x] **Step 5: lint+커밋**
```bash
npx eslint src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git add src/features/snap/api/snap.stream.ts src/features/snap/api/snap.stream.test.ts
git commit -m "feat(snap): add best-effort cancelStream for backend cancel endpoint"
```
---
## Task 2: snapChatStore — isRevealing 2-플래그
**Files:** Modify `src/features/snap/store/snapChatStore.ts` · Test `src/features/snap/store/snapChatStore.test.ts`
**Produces:** state `isRevealing: boolean`; action `setRevealing(v: boolean)`; `startAssistantMessage``isRevealing=true`; `stop()``isStreaming||isRevealing` 가드 + 둘 다 false + 마지막 assistant frozen; `dropEmptyAssistantTail` 가 드롭 시 `isRevealing=false` 도 clear.
- [x] **Step 1: 실패 테스트 추가**`snapChatStore.test.ts` 에 추가(기존 스타일; 각 it 앞 `reset()`):
```ts
it("startAssistantMessage 는 isRevealing 을 켠다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.startAssistantMessage()
expect(useSnapChatStore.getState().isRevealing).toBe(true)
})
it("setRevealing 으로 끌 수 있다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.setRevealing(true)
s.setRevealing(false)
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
it("stop 은 isStreaming/isRevealing 을 모두 끄고 마지막 assistant 를 frozen 처리", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
s.appendChunk("부분")
s.setStreaming(true)
useSnapChatStore.getState().stop()
const st = useSnapChatStore.getState()
expect(st.isStreaming).toBe(false)
expect(st.isRevealing).toBe(false)
expect(st.messages.at(-1)!.frozen).toBe(true)
})
it("dropEmptyAssistantTail 은 드롭 시 isRevealing 도 끈다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage() // isRevealing=true, 빈 assistant
useSnapChatStore.getState().dropEmptyAssistantTail()
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
```
- [x] **Step 2: 실패 확인**`npx vitest run src/features/snap/store/snapChatStore.test.ts` → FAIL.
- [x] **Step 3: 구현**`snapChatStore.ts` 수정:
3-1. `SnapChatState` 인터페이스에 추가(`isStreaming` 근처 + 액션):
```ts
isRevealing: boolean
```
```ts
setRevealing: (v: boolean) => void
```
3-2. 초기값에 `isRevealing: false,` 추가(`isStreaming: false,` 옆).
3-3. `seed``set({...})``isRevealing: false,` 추가(기존 `isStreaming: false,` 옆).
3-4. `startAssistantMessage` 를 isRevealing 도 켜게:
```ts
startAssistantMessage: () =>
set((s) => ({
messages: [...s.messages, { id: randomId(), role: "assistant" as SnapRole, content: "" }],
isRevealing: true,
})),
```
3-5. `setStreaming` 아래에 `setRevealing` 추가:
```ts
setRevealing: (v) => set({ isRevealing: v }),
```
3-6. `stop()` 을 가드 + isRevealing clear 로 교체:
```ts
stop: () => {
if (!get().isStreaming && !get().isRevealing) return
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,
isRevealing: false,
currentController: null,
}))
},
```
3-7. `dropEmptyAssistantTail` 이 드롭 시 isRevealing 도 끄게:
```ts
dropEmptyAssistantTail: () => {
const last = get().messages.at(-1)
if (!last || last.role !== "assistant" || last.content !== "") return
set((s) => ({ messages: s.messages.slice(0, -1), isRevealing: false }))
},
```
3-8. `reset()``set({...})``isRevealing: false,` 추가.
- [x] **Step 4: 통과 확인** — 위 명령 PASS(기존 테스트 포함).
- [x] **Step 5: lint+커밋**
```bash
npx eslint src/features/snap/store/snapChatStore.ts src/features/snap/store/snapChatStore.test.ts
git add src/features/snap/store/snapChatStore.ts src/features/snap/store/snapChatStore.test.ts
git commit -m "feat(snap): add isRevealing two-flag to chat store for post-network typewriter"
```
---
## Task 3: useSnapChat — stop→cancel, retry, isRevealing 유지
**Files:** Modify `src/features/snap/hooks/useSnapChat.ts` · Test `src/features/snap/hooks/useSnapChat.test.tsx`
**Consumes:** `cancelStream`(T1), store `isRevealing`/`setRevealing`/`stop`(T2).
**Produces:** `useSnapChat(id)``{ send, stop, retry }`. `stop` = store.stop() + `cancelStream(id)`. `retry` = 마지막 유저 재전송. `send``finally``isStreaming` 만 끔(isRevealing 유지).
- [x] **Step 1: 실패 테스트 추가**`useSnapChat.test.tsx` 에 추가. 상단에 `import * as streamApi from "../api/snap.stream"` 는 이미 `vi.mock("../api/snap.stream")` 로 자동 mock 됨 → `cancelStream` 도 mock 됨.
```ts
it("stop 은 store.stop 후 cancelStream(id) 를 부른다", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
result.current.stop()
expect(vi.mocked(stream.cancelStream)).toHaveBeenCalledWith("s1")
expect(useSnapChatStore.getState().isStreaming).toBe(false)
})
it("retry 는 마지막 유저 메시지를 다시 보낸다", async () => {
useSnapChatStore.getState().reset()
useSnapChatStore.getState().addUserMessage("원래 질문")
vi.mocked(stream.snapStream).mockResolvedValue(undefined)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.retry()
expect(vi.mocked(stream.snapStream)).toHaveBeenCalled()
const req = vi.mocked(stream.snapStream).mock.calls[0][0]
expect(req.content).toBe("원래 질문")
})
```
주의: `vi.mock("../api/snap.stream")` 는 자동 mock 이라 `cancelStream`·`snapStream` 둘 다 `vi.fn()`. 기존 테스트가 `vi.mock("../api/snap.stream")` 를 이미 선언했으면 그대로 사용. `retry` 는 동기 반환(void)이라 `await result.current.retry()` 가능하도록 아래 구현은 `retry``() => void` 로 둔다(내부에서 `void send(...)`). 테스트의 `await`는 마이크로태스크 flush 용.
- [x] **Step 2: 실패 확인**`npx vitest run src/features/snap/hooks/useSnapChat.test.tsx` → FAIL(retry/cancel 없음).
- [x] **Step 3: 구현**`useSnapChat.ts`:
3-1. import 에 `cancelStream` 추가:
```ts
import { snapStream, cancelStream } from "../api/snap.stream"
```
3-2. `send``finally` 는 그대로(이미 isStreaming 만 끔; isRevealing 은 안 건드림 — 유지 확인만).
3-3. `stop` 을 교체:
```ts
const stop = useCallback(() => {
useSnapChatStore.getState().stop()
void cancelStream(sessionId) // 백엔드 취소 통보(best-effort)
}, [sessionId])
```
3-4. `retry` 추가(return 위):
```ts
const retry = useCallback(() => {
const q = useSnapChatStore.getState().getRetryQuery()
if (q) void send(q)
}, [send])
```
3-5. return 을 `{ send, stop, retry }` 로.
- [x] **Step 4: 통과 확인** — 위 명령 PASS(기존 테스트 포함).
- [x] **Step 5: lint+커밋**
```bash
npx eslint src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git add src/features/snap/hooks/useSnapChat.ts src/features/snap/hooks/useSnapChat.test.tsx
git commit -m "feat(snap): stop fires cancelStream, add retry, keep isRevealing"
```
---
## Task 4: SessionChatPage + Composer 배선
**Files:** Modify `src/features/snap/pages/SessionChatPage.tsx`, `src/features/snap/components/Composer.tsx`
**Consumes:** store `isRevealing`/`setRevealing`(T2), `retry`(T3), `StoppedNotice` from `@/lib/streaming`.
- [x] **Step 1: import 추가** — SessionChatPage 상단:
```ts
import { StreamingText, StoppedNotice } from "@/lib/streaming"
```
그리고 `useSnapChat` 구조분해에 `retry` 추가: `const { send, stop, retry } = useSnapChat(id)`.
- [x] **Step 2: isRevealing 구독** — 셀렉터 확장:
```ts
const { messages, isStreaming, isRevealing } = useSnapChatStore(
useShallow((s) => ({ messages: s.messages, isStreaming: s.isStreaming, isRevealing: s.isRevealing })),
)
const busy = isStreaming || isRevealing
```
- [x] **Step 3: 라이브/리빌 판정 교체** — 기존 `liveLastIdx``isLiveLast``busy` 기준으로:
3-1. `liveLastIdx` 조건의 `isStreaming &&``busy &&` 로 바꾼다(마지막 assistant + !frozen 은 유지).
3-2. 렌더 루프의 `isLiveLast`:
```ts
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
```
3-3. 라이브 버블의 `<StreamingText text={m.content} isStreaming />` 를 아래로(타이핑 pace 는 isStreaming, 리빌 끝 통지 추가):
```tsx
<StreamingText
text={m.content}
isStreaming={isStreaming}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
```
- [x] **Step 4: frozen 버블 아래 StoppedNotice(옵션 B)** — 루프 끝 `return <Message .../>` 를, frozen assistant 면 StoppedNotice 를 곁들이게:
```tsx
if (m.frozen && m.role === "assistant") {
return (
<div key={m.id} className="flex flex-col gap-2">
<Message role={m.role} content={m.content} codeIndexBase={base} />
<StoppedNotice onRetry={retry} disabled={busy} />
</div>
)
}
return <Message key={m.id} role={m.role} content={m.content} codeIndexBase={base} />
```
- [x] **Step 5: Composer busy** — Composer 렌더에 `busy={busy}` 전달(기존 `isStreaming` 넘기던 자리 교체). onStop 은 그대로 `stop`.
(Composer.tsx 자체는 이미 `busy`/`onStop` prop 을 받으므로 컴포넌트 수정 불필요 — 넘기는 값만 `busy`.)
- [x] **Step 6: 검증**
```bash
npx tsc --noEmit
npx eslint src/features/snap/pages/SessionChatPage.tsx src/features/snap/components/Composer.tsx
npx vitest run src/features/snap
npm run build
```
전부 통과.
- [x] **Step 7: 커밋**
```bash
git add src/features/snap/pages/SessionChatPage.tsx src/features/snap/components/Composer.tsx
git commit -m "feat(snap): wire stop UX — isRevealing busy, live typewriter reveal, StoppedNotice+retry"
```
---
## 검증(수동) — ✅ 완료 확인
백엔드 8001 + `npm run dev`(15173) → 세션에서 스트리밍 중 **중단** → 부분 답변 유지 + "응답이 중단되었습니다" + 재시도 버튼. 타이핑 tail 도는 동안에도 중단 버튼 유지. 재시도 → 마지막 질문 다시 전송(백엔드 취소 미구현이면 409 토스트). 백엔드 cancel 구현 후엔 재시도 즉시 동작.
## 미룸
- ~~출력속도(instant) 토글~~ — 완료.
- 백엔드 `POST /chat/sessions/{id}/cancel` 구현(사용자 담당) — **추후로 미룸**. 미구현이라 재시도는 best-effort(로컬 freeze)만 동작.
@@ -0,0 +1,794 @@
# 윈도우 데스크톱 런처 (WebView2 + 2_frontend) 구현 계획
> **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:** `2_frontend` React SPA 를 WPF+WebView2 창에 담아, 전역 단축키(`Ctrl+Alt+Space`)로 소환하는 윈도우 런처 셸을 만든다.
**Architecture:** V1(`d:\project\021.code-assistant\3_windowsApp\`)의 런처 껍데기(`CodeAssist.Shell`)를 이식하고, 챗/인증 네이티브 로직은 버린다. WebView2 는 DEBUG=vite dev(핫리로드)/RELEASE=가상호스트(dist)로 React 앱을 로드. 상태·통신은 전부 React 가 `/api` 로 직접 처리하므로 닷넷은 순수 셸.
**Tech Stack:** .NET 8 (`net8.0-windows`), WPF, `Microsoft.Web.WebView2`, `H.NotifyIcon.Wpf`(트레이), xUnit(테스트). Win32 P/Invoke(핫키·포그라운드).
## Global Constraints
- 대상 프레임워크: `net8.0-windows` (모든 프로젝트 동일)
- `Nullable` enable, `ImplicitUsings` enable (모든 프로젝트)
- 코드 주석: 한글·반말 톤 (CLAUDE.md 0번)
- 신규 코드는 전부 `3_windowsApp/` 아래. `2_frontend/`**절대 수정 금지**(그대로 담기만 함).
- 이식원(참고 전용, 수정하지 말 것): `d:\project\021.code-assistant\3_windowsApp\`
- 전역 단축키: `Ctrl+Alt+Space` (modifiers `0x0001|0x0002`, vk `0x20`)
- vite dev 포트: `15173` (`2_frontend/vite.config.ts``server.port` 와 반드시 일치)
- **스코프 밖(다음 단계)**: Entra/MSAL 데스크톱 로그인, RELEASE `/api` 프록시, 브릿지(paste/hide/resize), Core 프로젝트. Core 는 나중에 되살릴 때 V1 `CodeAssist.Core` 참고.
- 사전조건: WebView2 Evergreen 런타임 설치돼 있어야 함(Win11 기본 포함). `node`/`npm` PATH 에 있어야 DEBUG 동작.
---
### Task 1: 솔루션 + Shell 골격 + 창 위치 로직 (TDD)
`WindowPlacement`(순수 로직)와 `JsonWindowPlacementStore`(파일 IO)를 TDD 로 이식한다. 나머지 Win32/UI 글루는 Task 2~5 에서 빌드·실행으로 검증(유닛테스트 불가 영역).
**Files:**
- Create: `3_windowsApp/CodeAssist.sln`
- Create: `3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj`
- Create: `3_windowsApp/CodeAssist.Shell/Window/WindowPlacement.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Window/IWindowPlacementStore.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Window/JsonWindowPlacementStore.cs`
- Create: `3_windowsApp/CodeAssist.Tests/CodeAssist.Tests.csproj`
- Create: `3_windowsApp/CodeAssist.Tests/WindowPlacementTests.cs`
- Create: `3_windowsApp/CodeAssist.Tests/JsonWindowPlacementStoreTests.cs`
- Modify: `.gitignore` (루트 — bin/obj 제외)
**Interfaces:**
- Produces:
- `record WindowPlacement(double Left, double Top, double Width, double Height)` + `bool IsVisibleWithin(double vsLeft, double vsTop, double vsWidth, double vsHeight)`
- `interface IWindowPlacementStore { WindowPlacement? Load(); void Save(WindowPlacement placement); }`
- `class JsonWindowPlacementStore : IWindowPlacementStore`, 생성자 `JsonWindowPlacementStore(string? path = null)`
- [ ] **Step 1: 솔루션·프로젝트 생성**
```bash
cd D:/project/021.code-assistant-v2/3_windowsApp
dotnet new sln -n CodeAssist
dotnet new classlib -n CodeAssist.Shell -f net8.0-windows
dotnet new xunit -n CodeAssist.Tests -f net8.0-windows
# classlib 기본 Class1.cs 제거
rm CodeAssist.Shell/Class1.cs
rm CodeAssist.Tests/UnitTest1.cs
dotnet sln add CodeAssist.Shell/CodeAssist.Shell.csproj CodeAssist.Tests/CodeAssist.Tests.csproj
dotnet add CodeAssist.Tests/CodeAssist.Tests.csproj reference CodeAssist.Shell/CodeAssist.Shell.csproj
```
- [ ] **Step 2: Shell csproj 를 아래로 교체** (`UseWPF` — Clipboard·Window 타입 때문에 Shell 도 WPF 참조)
`3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.1.3" />
</ItemGroup>
</Project>
```
- [ ] **Step 3: 실패하는 테스트 작성** (WindowPlacement)
`3_windowsApp/CodeAssist.Tests/WindowPlacementTests.cs`:
```csharp
using CodeAssist.Shell.Window;
using Xunit;
namespace CodeAssist.Tests;
public class WindowPlacementTests
{
private const double VsL = 0, VsT = 0, VsW = 1920, VsH = 1080;
[Fact]
public void IsVisibleWithin_fully_inside_true()
=> Assert.True(new WindowPlacement(100, 100, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH));
[Fact]
public void IsVisibleWithin_fully_offscreen_false()
=> Assert.False(new WindowPlacement(3000, 3000, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH));
[Fact]
public void IsVisibleWithin_tiny_sliver_false() // 20px 만 걸침(<80)
=> Assert.False(new WindowPlacement(1900, 100, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH));
[Fact]
public void IsVisibleWithin_enough_overlap_true() // 120px 걸침(>=80)
=> Assert.True(new WindowPlacement(1800, 100, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH));
}
```
- [ ] **Step 4: 컴파일 실패 확인**
Run: `dotnet test 3_windowsApp/CodeAssist.Tests`
Expected: FAIL — `WindowPlacement` 타입 없음(빌드 에러).
- [ ] **Step 5: WindowPlacement 이식** (V1 `CodeAssist.Shell/Window/WindowPlacement.cs` 와 동일)
`3_windowsApp/CodeAssist.Shell/Window/WindowPlacement.cs`:
```csharp
namespace CodeAssist.Shell.Window;
/// <summary>창의 마지막 위치·크기. 화면 밖 여부는 IsVisibleWithin 으로 판정.</summary>
public sealed record WindowPlacement(double Left, double Top, double Width, double Height)
{
// 복원 시 최소 이만큼은 화면 안에 보여야 "찾을 수 있다"(드래그 가능)고 본다.
private const double MinVisibleWidth = 80;
private const double MinVisibleHeight = 30;
/// <summary>이 창 사각형이 가상 화면(모든 모니터 합집합)과 충분히 겹쳐 보이는지.</summary>
public bool IsVisibleWithin(double vsLeft, double vsTop, double vsWidth, double vsHeight)
{
var overlapW = Math.Min(Left + Width, vsLeft + vsWidth) - Math.Max(Left, vsLeft);
var overlapH = Math.Min(Top + Height, vsTop + vsHeight) - Math.Max(Top, vsTop);
return overlapW >= MinVisibleWidth && overlapH >= MinVisibleHeight;
}
}
```
- [ ] **Step 6: 테스트 통과 확인**
Run: `dotnet test 3_windowsApp/CodeAssist.Tests`
Expected: PASS (4 passed).
- [ ] **Step 7: JsonWindowPlacementStore 실패 테스트 작성**
`3_windowsApp/CodeAssist.Tests/JsonWindowPlacementStoreTests.cs`:
```csharp
using System;
using System.IO;
using CodeAssist.Shell.Window;
using Xunit;
namespace CodeAssist.Tests;
public class JsonWindowPlacementStoreTests
{
private static string TempFile() =>
Path.Combine(Path.GetTempPath(), "ca-test-" + Guid.NewGuid().ToString("N") + ".json");
[Fact]
public void Load_missing_file_returns_null()
{
var store = new JsonWindowPlacementStore(TempFile());
Assert.Null(store.Load());
}
[Fact]
public void Save_then_Load_roundtrips()
{
var path = TempFile();
try
{
var store = new JsonWindowPlacementStore(path);
store.Save(new WindowPlacement(10, 20, 640, 520));
var loaded = store.Load();
Assert.Equal(new WindowPlacement(10, 20, 640, 520), loaded);
}
finally { File.Delete(path); }
}
[Fact]
public void Load_corrupt_json_returns_null()
{
var path = TempFile();
try
{
File.WriteAllText(path, "{ not valid json");
Assert.Null(new JsonWindowPlacementStore(path).Load());
}
finally { File.Delete(path); }
}
}
```
- [ ] **Step 8: 실패 확인**
Run: `dotnet test 3_windowsApp/CodeAssist.Tests`
Expected: FAIL — `JsonWindowPlacementStore`, `IWindowPlacementStore` 타입 없음.
- [ ] **Step 9: 인터페이스 + 구현 이식** (V1 동일 파일들)
`3_windowsApp/CodeAssist.Shell/Window/IWindowPlacementStore.cs`:
```csharp
namespace CodeAssist.Shell.Window;
/// <summary>창 위치·크기 저장소(로컬 파일).</summary>
public interface IWindowPlacementStore
{
WindowPlacement? Load();
void Save(WindowPlacement placement);
}
```
`3_windowsApp/CodeAssist.Shell/Window/JsonWindowPlacementStore.cs`:
```csharp
using System.IO;
using System.Text.Json;
namespace CodeAssist.Shell.Window;
/// <summary>%LocalAppData%\CodeAssist\window.json 에 평문 JSON 으로 저장. 이 PC 로컬 전용.</summary>
public sealed class JsonWindowPlacementStore : IWindowPlacementStore
{
private readonly string _path;
private static readonly JsonSerializerOptions Opts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public JsonWindowPlacementStore(string? path = null)
=> _path = path ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"CodeAssist", "window.json");
public WindowPlacement? Load()
{
if (!File.Exists(_path)) return null;
try { return JsonSerializer.Deserialize<WindowPlacement>(File.ReadAllText(_path), Opts); }
catch (JsonException) { return null; }
}
public void Save(WindowPlacement placement)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, JsonSerializer.Serialize(placement, Opts));
}
catch (Exception) { /* 위치 저장은 best-effort — 실패해도 흐름 막지 않음 */ }
}
}
```
- [ ] **Step 10: 전체 테스트 통과 확인**
Run: `dotnet test 3_windowsApp/CodeAssist.Tests`
Expected: PASS (7 passed).
- [ ] **Step 11: .gitignore 에 bin/obj 추가**
루트 `.gitignore` 에 아래 없으면 추가(있으면 skip):
```
3_windowsApp/**/bin/
3_windowsApp/**/obj/
3_windowsApp/.vs/
```
- [ ] **Step 12: 커밋**
```bash
git add 3_windowsApp/CodeAssist.sln 3_windowsApp/CodeAssist.Shell 3_windowsApp/CodeAssist.Tests .gitignore
git commit -m "feat(win): 솔루션 골격 + 창 위치 저장 로직(TDD)"
```
---
### Task 2: Shell 런처 원시기능 이식 (핫키·단일인스턴스·트레이·창베이스·vite런처)
Win32/트레이/프로세스 글루라 유닛테스트 대상 아님 — **빌드 성공**으로 검증. 아래 파일들은 V1 원본과 **동일**하게 이식(네임스페이스 그대로).
**Files:**
- Create: `3_windowsApp/CodeAssist.Shell/Platform/IHotKeyService.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Platform/HotKeyService.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Platform/ISingleInstanceGuard.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Platform/SingleInstanceGuard.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Platform/ViteDevServer.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Tray/ITrayIconHost.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Tray/TrayIconHost.cs`
- Create: `3_windowsApp/CodeAssist.Shell/Window/FramelessPaletteWindow.cs`
**Interfaces:**
- Produces:
- `interface IHotKeyService { bool Register(IntPtr hwnd, uint modifiers, uint vk); void ProcessMessage(int msg); event Action? HotKeyPressed; }` + `class HotKeyService : IHotKeyService, IDisposable`
- `interface ISingleInstanceGuard { bool TryAcquire(string name); }` + `class SingleInstanceGuard : ISingleInstanceGuard, IDisposable`
- `interface ITrayIconHost { void Show(string tooltip); void Notify(string title, string message); event Action? OpenRequested; event Action? ExitRequested; }` + `class TrayIconHost : ITrayIconHost, IDisposable`
- `class FramelessPaletteWindow : System.Windows.Window` (기본 생성자)
- `class ViteDevServer : IDisposable``void Start(string webDir)`, `Task<bool> WaitUntilReadyAsync(int port, TimeSpan timeout, CancellationToken ct = default)`
- [ ] **Step 1: 5개 원시기능 파일을 V1 에서 그대로 복사**
아래 원본을 내용 그대로 복사(네임스페이스·코드 무수정):
- `IHotKeyService.cs`, `HotKeyService.cs` ← V1 `CodeAssist.Shell/Platform/`
- `ISingleInstanceGuard.cs`, `SingleInstanceGuard.cs` ← V1 `CodeAssist.Shell/Platform/`
- `ViteDevServer.cs` ← V1 `CodeAssist.Shell/Platform/`
- `ITrayIconHost.cs`, `TrayIconHost.cs` ← V1 `CodeAssist.Shell/Tray/`
> 원본 경로: `d:\project\021.code-assistant\3_windowsApp\CodeAssist.Shell\...`. 파일 내용은 이미 확인됨(이 계획 작성 시점 기준). 복사 후 임의 수정 금지.
- [ ] **Step 2: FramelessPaletteWindow 이식** (V1 동일)
`3_windowsApp/CodeAssist.Shell/Window/FramelessPaletteWindow.cs`:
```csharp
using System.Windows;
namespace CodeAssist.Shell.Window;
/// <summary>표준 윈도우 창 베이스(제목표시줄·크기조절). 핫키/트레이로 소환, X(닫기)는 파생 클래스에서 숨김 처리.</summary>
public class FramelessPaletteWindow : System.Windows.Window
{
public FramelessPaletteWindow()
{
Title = "CodeAssist";
WindowStyle = WindowStyle.SingleBorderWindow;
ResizeMode = ResizeMode.CanResize;
ShowInTaskbar = true;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
Background = System.Windows.Media.Brushes.White;
}
}
```
- [ ] **Step 3: Shell 빌드 확인**
Run: `dotnet build 3_windowsApp/CodeAssist.Shell`
Expected: 빌드 성공 (0 Error). H.NotifyIcon.Wpf 복원됨.
- [ ] **Step 4: 커밋**
```bash
git add 3_windowsApp/CodeAssist.Shell
git commit -m "feat(win): Shell 런처 원시기능 이식(핫키·단일인스턴스·트레이·vite런처)"
```
---
### Task 3: App 프로젝트 + WebHostView (React 로더)
WPF exe 진입 프로젝트를 만들고, WebView2 에 `2_frontend` 를 로드하는 뷰를 넣는다. V1 `WebChatView` 에서 챗/인증/브릿지 전부 제거한 축약판.
**Files:**
- Create: `3_windowsApp/CodeAssist.App/CodeAssist.App.csproj`
- Create: `3_windowsApp/CodeAssist.App/App.xaml`
- Create: `3_windowsApp/CodeAssist.App/App.xaml.cs` (이 태스크선 최소 스텁 — Task 5 에서 채움)
- Create: `3_windowsApp/CodeAssist.App/Views/WebHostView.xaml`
- Create: `3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs`
- Modify: `3_windowsApp/CodeAssist.sln` (App 프로젝트 추가)
**Interfaces:**
- Consumes: `CodeAssist.Shell.Platform.ViteDevServer` (Task 2)
- Produces: `UserControl CodeAssist.App.Views.WebHostView` (기본 생성자, Loaded 시 자동 로드)
- [ ] **Step 1: App 프로젝트 생성 + 참조 배선**
```bash
cd D:/project/021.code-assistant-v2/3_windowsApp
dotnet new wpf -n CodeAssist.App -f net8.0-windows
rm CodeAssist.App/MainWindow.xaml CodeAssist.App/MainWindow.xaml.cs
dotnet sln add CodeAssist.App/CodeAssist.App.csproj
dotnet add CodeAssist.App/CodeAssist.App.csproj reference CodeAssist.Shell/CodeAssist.Shell.csproj
dotnet add CodeAssist.App/CodeAssist.App.csproj package Microsoft.Web.WebView2 --version 1.0.4022.49
```
- [ ] **Step 2: App csproj 를 아래로 교체** (WinExe + wwwroot content)
`3_windowsApp/CodeAssist.App/CodeAssist.App.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CodeAssist.Shell\CodeAssist.Shell.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
</ItemGroup>
<ItemGroup>
<!-- RELEASE 배포용: 2_frontend/dist 를 여기 wwwroot 로 복사해두면 WebView2 가 가상호스트로 물림.
(dist 복사 + /api 프록시는 다음 단계 — v1 은 DEBUG 로 검증) -->
<Content Include="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
```
- [ ] **Step 3: App.xaml 교체** (StartupUri 제거, 창 숨겨도 안 죽게 OnExplicitShutdown)
`3_windowsApp/CodeAssist.App/App.xaml`:
```xml
<Application x:Class="CodeAssist.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown" />
```
- [ ] **Step 4: App.xaml.cs 최소 스텁** (Task 5 에서 본체 채움 — 지금은 빌드만 되게)
`3_windowsApp/CodeAssist.App/App.xaml.cs`:
```csharp
using System.Windows;
namespace CodeAssist.App;
public partial class App : Application
{
}
```
- [ ] **Step 5: WebHostView.xaml 작성**
`3_windowsApp/CodeAssist.App/Views/WebHostView.xaml`:
```xml
<UserControl x:Class="CodeAssist.App.Views.WebHostView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf">
<wv2:WebView2 x:Name="Web"/>
</UserControl>
```
- [ ] **Step 6: WebHostView.xaml.cs 작성** (V1 WebChatView 에서 브릿지·챗·리사이즈 전부 제거)
`3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs`:
```csharp
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Web.WebView2.Core;
namespace CodeAssist.App.Views;
/// <summary>
/// WebView2 안에 2_frontend React 앱을 띄우는 호스트.
/// - DEBUG: ViteDevServer 로 2_frontend 의 npm run dev 를 띄우고 localhost:15173 을 물림(핫리로드).
/// - RELEASE: 출력 폴더의 wwwroot(2_frontend/dist 복사본)를 가상 호스트로 물림.
/// (주의: RELEASE 는 /api 프록시가 없어 백엔드 호출 안 됨 — auth 스코프와 함께 다음 단계.)
/// 초기화/네비 과정을 temp\codeassist-webview.log 에 남기고, 실패 시 에러 HTML 표시.
/// </summary>
public partial class WebHostView : UserControl
{
private const int DevPort = 15173; // 2_frontend/vite.config.ts 의 server.port 와 일치
private static readonly string LogPath = Path.Combine(Path.GetTempPath(), "codeassist-webview.log");
#if DEBUG
private readonly CodeAssist.Shell.Platform.ViteDevServer _vite = new();
#endif
public WebHostView()
{
InitializeComponent();
Loaded += OnLoaded;
#if DEBUG
Unloaded += (_, _) => _vite.Dispose();
#endif
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
try
{
Log("OnLoaded 시작");
// UserDataFolder 명시(exe 옆이 쓰기 불가일 때 초기화 실패 방지). 초기화 전에만 설정 가능.
Web.CreationProperties = new CoreWebView2CreationProperties
{
UserDataFolder = Path.Combine(Path.GetTempPath(), "CodeAssist.WebView2"),
};
await Web.EnsureCoreWebView2Async();
Log("CoreWebView2 준비됨");
Web.NavigationCompleted += (_, args) =>
Log($"NavigationCompleted success={args.IsSuccess} status={args.WebErrorStatus}");
var settings = Web.CoreWebView2.Settings;
settings.AreDefaultContextMenusEnabled = false;
settings.IsZoomControlEnabled = false;
#if DEBUG
settings.AreDevToolsEnabled = true;
string webDir = ResolveFrontendDir();
Log($"webDir={webDir} port={DevPort} (존재={Directory.Exists(webDir)})");
_vite.Start(webDir);
bool ready = await _vite.WaitUntilReadyAsync(DevPort, TimeSpan.FromSeconds(30));
Log($"vite ready={ready}");
if (ready)
Web.CoreWebView2.Navigate($"http://localhost:{DevPort}");
else
Web.CoreWebView2.NavigateToString(ErrorHtml(
"vite dev 서버가 30초 안에 안 떴음.",
$"webDir: {webDir}\n수동 확인: 그 폴더에서 npm run dev"));
#else
settings.AreDevToolsEnabled = false;
string wwwroot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
Log($"wwwroot={wwwroot} (존재={Directory.Exists(wwwroot)})");
Web.CoreWebView2.SetVirtualHostNameToFolderMapping(
"appassets.example", wwwroot, CoreWebView2HostResourceAccessKind.Allow);
Web.CoreWebView2.Navigate("https://appassets.example/index.html");
#endif
Log("navigate 호출됨");
}
catch (Exception ex)
{
Log("예외: " + ex);
try { Web.CoreWebView2?.NavigateToString(ErrorHtml("WebView2 초기화 실패", ex.ToString())); }
catch { /* CoreWebView2 자체가 없으면 표시 방법도 없음 — 로그로만 */ }
}
}
private static string ErrorHtml(string title, string detail)
=> $"<html><body style='font-family:Segoe UI;padding:24px'>" +
$"<h2 style='color:#c0392b'>{System.Net.WebUtility.HtmlEncode(title)}</h2>" +
$"<pre style='white-space:pre-wrap;color:#444'>{System.Net.WebUtility.HtmlEncode(detail)}</pre>" +
$"<p style='color:#888'>로그: {System.Net.WebUtility.HtmlEncode(LogPath)}</p></body></html>";
private static void Log(string msg)
{
try { File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss.fff} {msg}{Environment.NewLine}"); }
catch { /* 로그 실패는 무시 */ }
}
#if DEBUG
// dev: 이 파일 위치에서 리포 루트의 2_frontend 를 역산.
// Views → CodeAssist.App → 3_windowsApp → <repo루트> → 2_frontend
private static string ResolveFrontendDir([CallerFilePath] string thisFile = "")
{
string viewsDir = Path.GetDirectoryName(thisFile)!;
return Path.GetFullPath(Path.Combine(viewsDir, "..", "..", "..", "2_frontend"));
}
#endif
}
```
- [ ] **Step 7: App 빌드 확인**
Run: `dotnet build 3_windowsApp/CodeAssist.App`
Expected: 빌드 성공 (0 Error).
- [ ] **Step 8: 커밋**
```bash
git add 3_windowsApp/CodeAssist.App 3_windowsApp/CodeAssist.sln
git commit -m "feat(win): App 프로젝트 + WebHostView(2_frontend WebView2 로더)"
```
---
### Task 4: PaletteWindow (호스트 창 + 위치기억 + 숨김처리)
`WebHostView` 를 담는 실제 창. 위치·크기 복원/저장, X→숨김, (RELEASE) blur→숨김.
**Files:**
- Create: `3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml`
- Create: `3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml.cs`
**Interfaces:**
- Consumes: `FramelessPaletteWindow`(Task 2), `IWindowPlacementStore`/`WindowPlacement`(Task 1), `WebHostView`(Task 3)
- Produces: `class PaletteWindow : FramelessPaletteWindow`, 생성자 `PaletteWindow(IWindowPlacementStore placementStore)`, 속성 `bool AllowClose`
- [ ] **Step 1: PaletteWindow.xaml 작성**
`3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml`:
```xml
<shell:FramelessPaletteWindow
x:Class="CodeAssist.App.Views.PaletteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:CodeAssist.Shell.Window;assembly=CodeAssist.Shell"
xmlns:views="clr-namespace:CodeAssist.App.Views"
Width="960" Height="680">
<views:WebHostView x:Name="Web"/>
</shell:FramelessPaletteWindow>
```
- [ ] **Step 2: PaletteWindow.xaml.cs 작성** (V1 에서 챗 의존성 제거, blur/close-to-hide 되살림)
`3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml.cs`:
```csharp
using System.ComponentModel;
using System.Windows;
using CodeAssist.Shell.Window;
namespace CodeAssist.App.Views;
public partial class PaletteWindow : FramelessPaletteWindow
{
/// <summary>트레이 '종료' 등 진짜 끌 때만 true. 평소 X 는 숨김 처리.</summary>
public bool AllowClose { get; set; }
private readonly IWindowPlacementStore _placementStore;
public PaletteWindow(IWindowPlacementStore placementStore)
{
InitializeComponent();
_placementStore = placementStore;
RestorePlacement(); // Show 전에 위치·크기 복원
IsVisibleChanged += (_, _) => { if (!IsVisible) SaveCurrentPlacement(); };
#if !DEBUG
// 포커스 잃으면 자동 숨김(wox 방식). DEBUG 선 끔 — DevTools 열 때마다 창이 숨어 개발 불가.
Deactivated += (_, _) => { if (!AllowClose) Hide(); };
#endif
}
/// <summary>저장된 위치·크기가 화면 안이면 복원. 없거나 화면 밖이면 CenterScreen 유지.</summary>
private void RestorePlacement()
{
var saved = _placementStore.Load();
if (saved is null) return;
if (!saved.IsVisibleWithin(
SystemParameters.VirtualScreenLeft, SystemParameters.VirtualScreenTop,
SystemParameters.VirtualScreenWidth, SystemParameters.VirtualScreenHeight))
return;
WindowStartupLocation = WindowStartupLocation.Manual;
Left = saved.Left; Top = saved.Top; Width = saved.Width; Height = saved.Height;
}
/// <summary>현재 위치·크기 저장. 최소화/최대화·이상값이면 skip.</summary>
private void SaveCurrentPlacement()
{
if (WindowState != WindowState.Normal) return;
if (Width <= 0 || Height <= 0) return;
if (double.IsNaN(Left) || double.IsNaN(Top)) return; // CenterScreen 미표시 창은 좌표 NaN
_placementStore.Save(new WindowPlacement(Left, Top, Width, Height));
}
// X(닫기)는 종료 대신 숨김. 트레이 '종료'가 AllowClose=true 로 진짜 종료.
protected override void OnClosing(CancelEventArgs e)
{
SaveCurrentPlacement();
if (!AllowClose) { e.Cancel = true; Hide(); }
base.OnClosing(e);
}
}
```
- [ ] **Step 3: 빌드 확인**
Run: `dotnet build 3_windowsApp/CodeAssist.App`
Expected: 빌드 성공 (0 Error).
- [ ] **Step 4: 커밋**
```bash
git add 3_windowsApp/CodeAssist.App/Views
git commit -m "feat(win): PaletteWindow — 위치기억 + X/blur 숨김 처리"
```
---
### Task 5: App 배선 (단일인스턴스·핫키·트레이·토글) + 실행 검증
셸을 하나로 잇는다. GUI 최종 동작이라 **수동 실행**으로 검증(성공 기준 = 설계 §9).
**Files:**
- Modify: `3_windowsApp/CodeAssist.App/App.xaml.cs` (Task 3 스텁 → 본체)
**Interfaces:**
- Consumes: `SingleInstanceGuard`, `HotKeyService`, `TrayIconHost`(Task 2), `JsonWindowPlacementStore`(Task 1), `PaletteWindow`(Task 4)
- [ ] **Step 1: App.xaml.cs 본체 작성** (DI 없이 직접 배선 — 셸 서비스 소수라 new 로 충분)
`3_windowsApp/CodeAssist.App/App.xaml.cs`:
```csharp
using System.Windows;
using System.Windows.Interop;
using CodeAssist.App.Views;
using CodeAssist.Shell.Platform;
using CodeAssist.Shell.Tray;
using CodeAssist.Shell.Window;
namespace CodeAssist.App;
public partial class App : Application
{
private const string MutexName = "CodeAssist-v2-9a1f2b6c";
// MOD_ALT(0x1) | MOD_CONTROL(0x2), VK_SPACE(0x20)
private const uint ModCtrlAlt = 0x0001 | 0x0002;
private const uint VkSpace = 0x20;
private SingleInstanceGuard _guard = null!;
private HotKeyService _hotkeys = null!;
private TrayIconHost _tray = null!;
private PaletteWindow? _palette;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 단일 인스턴스 — 두 번째면 조용히 종료
_guard = new SingleInstanceGuard();
if (!_guard.TryAcquire(MutexName)) { Shutdown(); return; }
// 빈 팔레트 창 준비(아직 안 띄움) — 핫키용 HWND 확보
_palette = new PaletteWindow(new JsonWindowPlacementStore());
var helper = new WindowInteropHelper(_palette);
helper.EnsureHandle();
HwndSource.FromHwnd(helper.Handle)!.AddHook(WndProc);
// 전역 핫키 — 실패해도 죽지 말고 트레이로 안내
_hotkeys = new HotKeyService();
_hotkeys.HotKeyPressed += () => Dispatcher.Invoke(TogglePalette);
bool ok = _hotkeys.Register(helper.Handle, ModCtrlAlt, VkSpace);
// 트레이 상주
_tray = new TrayIconHost();
_tray.OpenRequested += () => Dispatcher.Invoke(ShowPalette);
_tray.ExitRequested += () => Dispatcher.Invoke(() =>
{
if (_palette is not null) _palette.AllowClose = true; // X 가로채기 풀고 진짜 종료
Shutdown();
});
_tray.Show("CodeAssist");
if (!ok) _tray.Notify("단축키 등록 실패", "Ctrl+Alt+Space 가 선점됨 — 트레이 '열기'로 호출");
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
_hotkeys.ProcessMessage(msg);
return IntPtr.Zero;
}
private void TogglePalette()
{
if (_palette is null) return;
if (_palette.IsVisible) _palette.Hide();
else ShowPalette();
}
private void ShowPalette()
{
if (_palette is null) return;
_palette.Show();
_palette.Activate();
}
protected override void OnExit(ExitEventArgs e)
{
_hotkeys?.Dispose();
_tray?.Dispose();
_guard?.Dispose();
base.OnExit(e);
}
}
```
- [ ] **Step 2: 빌드 확인**
Run: `dotnet build 3_windowsApp/CodeAssist.App`
Expected: 빌드 성공 (0 Error).
- [ ] **Step 3: 실행 검증** (수동 — GUI)
사전: `2_frontend` 에서 의존성 설치돼 있어야 함(`cd 2_frontend && npm install` 한 번). 백엔드는 없어도 됨(React 렌더까지만 확인).
Run: `dotnet run --project 3_windowsApp/CodeAssist.App`
확인(설계 §9 성공 기준):
- [ ] 시작 시 창 안 뜨고 트레이 아이콘만 상주
- [ ] `Ctrl+Alt+Space` → 창 뜸 → 다시 누르면 숨음
- [ ] 창 안에 2_frontend React 앱이 렌더됨(로그인/챗 화면). vite 핫리로드 동작(2_frontend 코드 고치면 반영)
- [ ] 창 X 클릭 → 종료 아니라 숨김 / 트레이 우클릭 '열기' → 다시 뜸
- [ ] 트레이 우클릭 '종료' → 앱 완전 종료(트레이 아이콘 사라짐)
- [ ] 앱 켠 채로 한 번 더 `dotnet run` → 두 번째 인스턴스 즉시 종료(단일 인스턴스)
- [ ] 창 위치/크기 옮기고 숨겼다 다시 열면 그 위치·크기로 복원
문제 시 로그 확인: `%TEMP%\codeassist-webview.log`, `%TEMP%\codeassist-vite.log`
- [ ] **Step 4: 커밋**
```bash
git add 3_windowsApp/CodeAssist.App/App.xaml.cs
git commit -m "feat(win): App 배선(단일인스턴스·핫키·트레이·토글) — v1 셸 완성"
```
---
## 완료 후
- `specs/`(spec-kit) 안 쓰고 이 계획 하나로 진행(B 스코프 단일 셸이라 분해 불필요).
- v1 셸 검증되면 다음 단계 후보(설계 §7·§8): RELEASE `/api` 프록시 + dist 패키징 + Entra 로그인 + 브릿지(paste/hide/resize). 그때 V1 `Core`·`WebBridgeProtocol` 참고.
## Self-Review (작성자 점검 결과)
- **Spec coverage:** 설계 §4 동작흐름→Task5, §5 단축키/창→Task2·4·5, §6 A/B로딩→Task3, §9 성공기준→Task5 Step3 로 전부 매핑됨. §7(RELEASE /api)·§8(제외항목)은 의도적으로 다음 단계.
- **Placeholder scan:** 코드 스텁(App.xaml.cs Task3)은 Task5 에서 전체 교체됨을 명시 — 미완성 방치 아님. 그 외 TBD/TODO 없음.
- **Type consistency:** `IWindowPlacementStore.Load/Save`, `WindowPlacement(Left,Top,Width,Height)`, `HotKeyService.Register/ProcessMessage/HotKeyPressed`, `TrayIconHost.Show/Notify/OpenRequested/ExitRequested`, `PaletteWindow(IWindowPlacementStore)`+`AllowClose`, `ViteDevServer.Start/WaitUntilReadyAsync` — 태스크 간 시그니처 일치 확인함.
@@ -0,0 +1,212 @@
# Snap Mate Client — React 이식 설계
> new-chat.html 목업(3뷰 채팅 클라이언트)을 기존 2_frontend 코드베이스 위에 React로 구현.
> 최종 목적지: .NET 윈도우 애플리케이션의 웹뷰.
작성일: 2026-07-16
---
## 1. 목표 & 범위
`new-chat.html`(958줄 self-contained 목업)의 경험을 React로 옮긴다. 3개 뷰:
- **홈** — 세션 목록 + 검색
- **채팅** — 세션 열어 과거대화 + 코드블럭 + 진짜 스트리밍 전송
- **새 대화** — 히어로 + 추천카드 → 첫 전송
**방침: 3화면 비주얼을 목업만큼 꽉 채운다.** 백엔드는 무조건 나중, 전제는 mock.
부가 UI(nav 레일·detail 패널·클립보드 배너)도 **이번에 비주얼로 다 보이게** 만들되,
깊은 로직(단축키 세트·실시간 클립보드 OS감지·real 백엔드)만 가볍게/2차.
### 이번 범위 (비주얼 완성)
- 전용 풀블리드 라우트 3개 (`/snap`, `/snap/new`, `/snap/s/:id`)
- 세션 목록 + 검색(클라 필터, 클릭 진입)
- 세션 열어 과거대화 렌더 (markdown + 코드블럭 + 복사)
- 스트리밍 전송 (컨트랙트만 뚫고 mock 토큰 방출 — 백엔드는 나중에)
- 새 대화 히어로 + 추천카드 → 전송 → 세션 진입
- **source-code 네비게이터 레일** — 코드블럭 목록 + 클릭 점프 (기본 동작까지)
- **세션 detail 패널** (Sheet) — 배지 클릭 시 세션 메타 표시
- **클립보드 감지 배너** — 목업처럼 타이머 데모(2.6s 후 등장) 수준의 비주얼
### 2차로 미룸 (범위 밖)
real 백엔드 부착 · 키보드 단축키 세트(↑↓/Ctrl+J/Alt화살표/Tab순환/Ctrl+Shift+C) 배선 · 실시간 클립보드 OS 읽기 · syntax highlight 강화 · 가짜 OS 타이틀바 · rename/delete/search 엔드포인트
---
## 2. 핵심 결정 (확정됨)
| 항목 | 결정 |
|---|---|
| 디자인 | 기존 **shadcn 컴포넌트로 흡수**. 별도 CSS 팔레트 왕국 안 만듦. editorial 성격(serif 이탤릭 타이틀·mono 대문자 eyebrow·따뜻한 accent)은 Tailwind 클래스로만 얹음 |
| 앱 셸 | **전용 풀블리드 라우트** (사이드바/DashboardLayout 밖). 기존 대시보드 라우트는 안 건드림 |
| 백엔드 계약 | **base-backend(000)의 세션 기반 계약에 정렬**. 지금은 **CONTRACT만 뚫고 mock**, UX/UI 집중 후 백엔드 부착 |
| 스트리밍 | 기존 `lib/streaming/streamLLM`(범용) 재사용. 기존 `useChatStream`/`chatStore`(무상태 `{messages}` 계약)는 안 건드림 |
| 폰트 | 웹뷰 오프라인 대비 — 새 CDN 폰트 안 붙임. 기존/시스템 폰트 + serif 폴백 |
---
## 3. 백엔드 계약 (base-backend `modules/chat`)
현재 2_frontend 채팅은 **무상태**(`{messages}` 통째 전송, 세션 없음 — echo 시대)라 그대로 못 쓴다.
base-backend는 **세션 기반**으로 진화했고, 이 UI는 그 계약에 맞춘다:
```
GET /chat/sessions → Envelope<ChatSessionResponse[]> (목록, 페이지네이션)
GET /chat/sessions/{id}/messages → Envelope<ChatSessionDetailResponse>
POST /chat/sessions → Envelope<ChatSessionResponse> (새 대화 = 세션 발급)
PATCH /chat/sessions/{id} → rename (2차)
DELETE /chat/sessions/{id} → soft delete (2차)
GET /chat/sessions/search?query= → 메시지 검색 (2차, 지금은 클라 필터)
POST /chat/stream {sessionId, content, forcedSkill?} → SSE
```
### 타입 (base-backend schema.py 와 1:1, camelCase)
```ts
interface ChatSessionResponse {
id: string
title: string | null
titleLlm: string | null
isGenerating: boolean
createdAt: string
updatedAt: string
}
interface ChatMessageResponse {
sessionId: string
role: "user" | "assistant" | "system"
content: string // markdown
createdAt: string
}
interface ChatSessionDetailResponse extends ChatSessionResponse {
messages: ChatMessageResponse[]
}
interface SnapStreamRequest { sessionId: string; content: string; forcedSkill?: string }
```
### 목업 ↔ 실제 필드 매핑
| 목업 세션카드 | 실제 | 처리 |
|---|---|---|
| title | `titleLlm ?? title` | ✓ |
| time | `updatedAt` (상대시간 포맷, date-fns) | ✓ |
| 라이브 dot | `isGenerating` | ✓ |
| tag / tokens / snippet | **백엔드에 없음** | mock엔 넣되 `// UI-only` 표기. real 스왑 시 snippet=마지막 메시지 미리보기 / tag·tokens 드롭·파생 |
---
## 4. 모듈 구조
```
src/features/snap/
contract/
types.ts # 위 계약 타입 (types/api.ts 에서 재사용/확장)
mock/ # ← 목업 격리. 백엔드 부착 = 이 폴더만 지우고 api 본문 스왑
sessions.ts # ChatSessionResponse[] + UI-only 확장(tag/snippet)
conversations.ts # Record<sessionId, ChatMessageResponse[]> (content=markdown, 코드펜스 포함)
stream.ts # mock 토큰 방출기 (실제 SSE 흉내)
api/
snap.api.ts # useSessionList / useSessionMessages / useCreateSession (TanStack Query)
# 지금 mock 반환. 주석에 "real = axios 이 줄" 스왑 지점 명시
snap.stream.ts # send({sessionId,content,forcedSkill?}) → 지금 mock/stream / 나중 streamLLM('/chat/stream')
store/
snapChatStore.ts # 현재 세션 1개의 messages + 스트리밍 상태 (chatStore 패턴 복제, 세션화)
components/
SnapLayout.tsx # 풀블리드 셸 + Toaster (가짜 타이틀바 없음, 얇은 브랜드 스트립)
SessionCard.tsx / SessionSearch.tsx
ChatHeader.tsx / Composer.tsx
Message.tsx # markdown 렌더 + 코드펜스 → CodeBlock. 라이브는 StreamingText
CodeBlock.tsx # mono 블럭 + 복사 버튼(navigator.clipboard). highlight 최소
NavRail.tsx # source-code 네비게이터 — 코드블럭 목록 + 클릭 점프
DetailPanel.tsx # 세션 메타 Sheet (배지 클릭)
ClipBanner.tsx # 클립보드 감지 배너 (타이머 데모 비주얼)
Hero.tsx / SuggestCard.tsx
pages/
SessionListPage.tsx # /snap
NewChatPage.tsx # /snap/new
SessionChatPage.tsx # /snap/s/:id
index.ts
```
### 재사용 (새로 안 짬)
- `lib/streaming/streamLLM` — 범용 `{path, body, signal, handlers}` 러너. snap.stream 이 여기에 얹힘
- `lib/streaming``StreamingText`(타자기) · `StoppedNotice` · `ClarifyChoices` 렌더
- `shared/ui/*` shadcn 프리미티브 (Button/Input/Card/Badge/Separator)
- `react-markdown` + `remark-gfm` (이미 설치됨) — 메시지 markdown 렌더
- `sonner` toast · `lucide-react` 아이콘 · `date-fns` 상대시간
### 안 건드림 (surgical)
- 기존 `features/chat/*` (무상태 계약 그대로, `/chat` 페이지 유지)
- `shared/components/DashboardLayout` · `AppSidebar` · 기존 라우트
---
## 5. 라우팅 & 셸
`config/routes.ts` PATHS 추가:
```ts
SNAP: "/snap",
SNAP_NEW: "/snap/new",
SNAP_SESSION: "/snap/s/:id",
```
`routes.tsx`(라우트 정의)에 `ProtectedRoute`(인증 필요) 안, **`DashboardLayout` 밖**에 `SnapLayout`(Outlet) 라우트 그룹으로 추가. 3 페이지가 이 셸을 공유. 웹뷰에선 .NET이 진짜 창을 주므로 가짜 min/max/close 타이틀바는 버리고 얇은 브랜드 스트립만.
---
## 6. 스트리밍 흐름
`snapChatStore` = 기존 `chatStore` 패턴(messages / isStreaming / isRevealing / controller / stop / retry)을 복제하되 "현재 세션 1개" 모델. 기존 chatStore·chat 페이지는 안 건드림.
- **세션 열기** (`/snap/s/:id`): `useSessionMessages(id)` → 받은 messages 로 store seed (스트리밍 아님)
- **전송**: `snapChatStore.addUserMessage` + `startAssistantMessage``snap.stream.send({sessionId, content})` → onToken 마다 `appendChunk`. 라이브 버블은 `StreamingText`
- **새 대화** (`/snap/new`): 첫 전송 시 `useCreateSession()` → 세션 id 받음 → `/snap/s/:id` 이동 → seed 없이 바로 전송
- 지금은 `snap.stream` 이 mock 토큰 방출. 백엔드 부착 = `streamLLM({ path:'/chat/stream', body:{sessionId,content,forcedSkill} })` 로 본문 교체
`forcedSkill`/clarify(라우팅 후보) 배선은 계약엔 넣되 UI(ClarifyChoices)는 2차에 붙여도 됨 — 슬라이스에선 토큰 스트림만 확실히.
---
## 7. 메시지 렌더링
지금 chat은 content가 평문(타자기)뿐. 목업엔 코드블럭이 있고 실제 LLM도 markdown을 뱉는다.
- **`Message`**: assistant content 를 `react-markdown`+`remark-gfm` 로 렌더. 코드펜스는 `components.code` 로 가로채 `CodeBlock` 렌더. user 는 평문(whitespace-pre-wrap)
- **`CodeBlock`**: 상단 바(lang 라벨 + 복사 버튼) + mono `<pre>`. 복사 = `navigator.clipboard` + toast. 블럭 내부 스크롤(목업처럼). syntax 색은 최소(2차 강화)
- **라이브 스트리밍 중**: 부분 markdown 이 깨질 수 있으니, 완료 전까지는 `StreamingText`(평문 타자기) → done 후 markdown 재렌더. (구현 시 부분 markdown 렌더 허용 여부 결정 — 기본은 완료 후 markdown)
---
## 8. 디자인 방침 (shadcn 흡수)
기존 테마 토큰(light/dark) + shadcn 컴포넌트 그대로. editorial 정체성은 Tailwind 클래스로만:
- 타이틀: `font-serif italic`
- eyebrow/메타: mono 대문자 + tracking
- 카드/보더: 기존 토큰(`bg-card`/`border`), 목업의 따뜻한 종이톤은 accent 정도로만 차용
- 별도 `.css` 팔레트 파일 안 만듦. 필요하면 Tailwind config 토큰만 최소 추가
---
## 9. Mock 격리 원칙 ("금방 없앨 수 있도록")
- 모든 mock 은 `features/snap/mock/` 한 폴더에만
- api/stream 훅은 **base-backend 계약 타입**을 반환/수신 (mock 도 그 모양)
- 스왑 지점 = `snap.api.ts` / `snap.stream.ts``// TODO(backend): mock → real` 주석 한 곳씩
- UI-only 필드(tag/snippet/tokens)는 명시적으로 표기해 real 스왑 시 안전하게 드롭/파생
---
## 10. 검증 기준 (verify)
각 페이지가 "됐다"를 화면으로 보여줄 수 있어야 (CLAUDE.md 10번 세로슬라이스):
1. `/snap` → mock 세션 목록 렌더, 검색어 입력 시 클라 필터 동작, 카드 클릭 → `/snap/s/:id` 이동
2. `/snap/s/:id` → 해당 세션 mock 과거대화 표시, 코드블럭 복사 버튼 동작(클립보드+toast)
3. 채팅 입력 후 전송 → user 버블 + mock 스트리밍 assistant 버블(타자기)
4. `/snap/new` → 히어로+추천카드, 추천 클릭 시 입력창 채움, 첫 전송 → 히어로 사라지고 세션 진입
5. **nav 레일** → 코드블럭 있는 세션에서 레일에 블럭 목록 뜨고, 클릭 시 해당 블럭으로 스크롤 점프
6. **detail 패널** → 채팅 헤더 배지 클릭 시 세션 메타 Sheet 열림/닫힘
7. **클립보드 배너** → 채팅 진입 후 타이머로 배너 등장, 무시/붙여넣기 버튼 동작
8. `npm run lint` 깨끗 · `npm run build` 성공 · 기존 `/chat` 등 대시보드 라우트 안 깨짐
---
## 11. 열린 질문 (구현 중 확정)
- 라이브 스트리밍 중 부분 markdown 렌더 허용 여부 (기본: 완료 후 markdown, 스트리밍 중엔 평문 타자기)
- Tailwind config 에 warm accent 토큰 추가할지 vs 기존 토큰으로만
- 상대시간 포맷 로케일(ko) — date-fns `formatDistanceToNow`
@@ -0,0 +1,158 @@
# Snap 백엔드 연결 설계
> snap 목업을 걷어내고 base-backend(000)의 세션 기반 chat 계약에 real 연결.
> 이전 spec(`2026-07-16-snap-mate-react-client-design.md`)의 "2차로 미룸 — real 백엔드 부착"을 실행.
> 최종 목적지: .NET 윈도우 애플리케이션의 웹뷰(WebView2).
작성일: 2026-07-18
---
## 1. 목표 & 범위
snap 은 이미 **교체 seam 이 다 잡혀 있다.** 새 구조를 만드는 게 아니라, 기존 seam 에
real 구현을 끼우고 목업을 제거하는 작업이다. UI/store/컴포넌트는 **건드리지 않는다**
(반환 타입만 유지하면 화면 코드는 안 바뀐다).
### 이번 범위 (세로 슬라이스)
| # | 대상 | 붙일 곳 |
|---|---|---|
| 1 | `useSessionList` | `GET /chat/sessions` (`apiList`) |
| 2 | `useSessionMessages` | `GET /chat/sessions/{id}/messages` (`apiGet`) |
| 3 | `useCreateSession` | `POST /chat/sessions` (`apiPost`) |
| 4 | `snap.stream` (`USE_MOCK=false`) | `POST /chat/stream` SSE (token/done/error/**title**) |
| 5 | 목업 3파일 제거 | `mock/sessions.ts` · `mock/conversations.ts` · `mock/stream.ts` + import 정리 |
| 6 | Bearer 토큰 provider seam | `client.ts` 요청 인터셉터 + `sse.ts` 헤더 |
### 2차로 미룸 (범위 밖)
- rename(PATCH) / delete / search 엔드포인트
- `subagent_start` / `subagent_done` 진행 UI
- `usage` 토큰 카운터
- Bearer 모드 refresh(호스트 위임) 실제 배선 — .NET 호스트 생기면
---
## 2. 핵심 결정 (확정됨)
| 항목 | 결정 |
|---|---|
| 방침 | 기존 seam 에 real 구현 주입 + 목업 제거. **UI/store/컴포넌트 0 변경** |
| 범위 | **세로 슬라이스** — list/get/create/stream 까지. rename/delete/search 는 2차 |
| envelope | 비스트림 응답은 `Envelope[T]``apiGet`/`apiPost`/`apiList` 헬퍼가 `.data` 자동 언랩 |
| title 이벤트 | **살린다** — 첫 메시지 후 백엔드가 쏘는 `title{title}` 로 세션 제목 자동 갱신 |
| subagent/usage | **무시**`streamLLM` 이 unknown 이벤트를 흘려서 안 살려도 안 깨짐. 2차 |
| 409 | `isGenerating` 이면 send 막고, stream open 이 409(`CHAT_GENERATION_IN_PROGRESS`)면 toast |
| 인증 | **쿠키 now + Bearer 주입 seam.** 토큰 소스를 `getAccessToken()` 한 곳으로. 있으면 `Authorization: Bearer`, 없으면(=오늘) 쿠키 폴백 |
| refresh | 쿠키 모드는 지금대로. Bearer 모드 refresh 는 .NET 호스트 책임 → 지금은 TODO 만 |
| dev 환경 | Vite proxy(`/api``localhost:8001`)로 same-origin → 쿠키/CORS 이슈 없음. 건드릴 것 없음 |
---
## 3. 백엔드 계약 (base-backend `modules/chat`)
베이스 URL: `http://localhost:8001` · 프리픽스 `/api/v1` · dev 는 Vite proxy 로 `/api/v1/...`.
### 3.1 비스트림 엔드포인트 (Envelope 래핑, camelCase)
| Method + Path | 용도 | 반환 `data` |
|---|---|---|
| `POST /chat/sessions` | 세션 생성 | `ChatSessionResponse` |
| `GET /chat/sessions` | 세션 목록(페이지네이션) | `ChatSessionResponse[]` |
| `GET /chat/sessions/{id}/messages` | 세션 + 메시지 | `ChatSessionDetailResponse` |
- `ChatSessionResponse`: `id, title, titleLlm, isGenerating, createdAt, updatedAt`
- `ChatMessageResponse`: `sessionId, role, content, createdAt`
- `ChatSessionDetailResponse`: 세션 필드 + `messages: ChatMessageResponse[]`
프론트 `contract/types.ts``SnapSession`/`SnapMessage`/`SnapSessionDetail` 와 1:1
(단, `SnapSession` 의 UI-only 필드 `tag?`/`snippet?`/`tokens?` 는 백엔드에 없음 — 옵셔널이라
없어도 안 깨지고, 화면에서 안 쓰면 그만).
### 3.2 스트림 엔드포인트
`POST /chat/stream``sse_starlette.EventSourceResponse` (POST + JSON body 라 브라우저
네이티브 `EventSource` 불가 → `@microsoft/fetch-event-source` 사용, 이미 `sse.ts` 가 그럼).
요청 바디 `ChatStreamRequest`: `{ sessionId, content, forcedSkill? }`.
이벤트 (named `event:` + JSON `data:`):
| event | data | 이번 처리 |
|---|---|---|
| `token` | `{delta}` | `appendChunk(delta)` |
| `title` | `{title}` | **세션 제목 갱신** (react-query 캐시 + 필요 시 store) |
| `done` | `{}` / `{traceId?}` | 스트림 종료 |
| `error` | `{message, code, detail?}` | toast + 마지막 assistant 메시지 frozen |
| `result` | `{items}` | (이번엔 pass — snap 은 안 씀) |
| `subagent_start`/`subagent_done` | `{name,...}` | 무시 (2차) |
| `usage` | `{used,limit,ratio,elapsed_ms}` | 무시 (2차) |
순서: (optional `title`/`result`/`subagent_*`) → 다수 `token``usage``done`,
또는 `error` 종결. SSE 는 200 으로 열려서 중간에 상태코드 못 바꿈 → LLM 설정/생성 실패는
HTTP 에러가 아니라 `error` **이벤트**로 옴. code: `LLM_NOT_CONFIGURED` /
`LLM_RATE_LIMITED` / `LLM_ERROR` / `UNKNOWN_SKILL`.
---
## 4. 유닛별 변경
### 4.1 `features/snap/api/snap.api.ts` — 목업 바디 → real 호출
세 함수의 `queryFn`/`mutationFn` 바디만 교체. 시그니처·반환타입·queryKey 유지.
- `useSessionList``apiList<SnapSession>("/chat/sessions")``items`
- `useSessionMessages(id)``apiGet<SnapSessionDetail>(\`/chat/sessions/${id}/messages\`)`
- `useCreateSession``apiPost<SnapSession>("/chat/sessions")`
- `MOCK_SESSIONS`/`MOCK_CONVERSATIONS` import 제거.
### 4.2 `features/snap/api/snap.stream.ts` — 플래그 + title 핸들러
- `USE_MOCK = false`.
- `SnapStreamHandlers``onTitle?(title: string)` 추가.
- real 경로: `streamLLM({ path: "/chat/stream", body: req, signal, handlers })`
`streamLLM` 에도 `onTitle` 배선 필요(4.4).
- `mockStream` import 제거.
### 4.3 `features/snap/hooks/useSnapChat.ts` — title 반영 + 409
- `onTitle` → react-query 세션 캐시(`["snap","sessions"]`, `["snap","session",id]`)의
`title` 패치(`queryClient.setQueryData`).
- send 진입 가드: 스토어 `isStreaming` 이거나 세션 `isGenerating` 이면 막음.
- stream open 이 409 면 `onError` 로 흘러오니 code 보고 "이미 생성 중" toast.
### 4.4 `lib/streaming/streamLLM.ts` — title 이벤트 파싱
- 이벤트 스위치에 `title``handlers.onTitle?.(payload.title)` 추가.
- `LLMStreamHandlers` 타입에 `onTitle?` 추가. 기존 소비자(다른 chat)는 옵셔널이라 영향 없음.
### 4.5 인증 seam — `getAccessToken()` provider
- 새 파일 `lib/auth/tokenProvider.ts`: `getAccessToken(): string | null` (기본 `null`) +
`setAccessToken(t: string | null)`. .NET 호스트가 나중에 `window.chrome.webview`
주입하면 여기만 채움.
- `lib/api/client.ts`: **요청 인터셉터** 추가 — 토큰 있으면 `Authorization: Bearer` 세팅
(없으면 아무것도 안 함 → 쿠키 그대로). 응답 인터셉터는 유지.
- `lib/streaming/sse.ts`: `open()` 헤더에 토큰 있으면 `Authorization` 추가.
- refresh: 쿠키 모드 유지. Bearer 모드는 `// TODO(host): .NET 호스트 refresh 위임` 주석만.
### 4.6 목업 제거
`mock/sessions.ts`·`mock/conversations.ts`·`mock/stream.ts` 삭제. 남은 import 없는지 확인
(4.1/4.2 에서 이미 끊음).
---
## 5. 검증
1. **백엔드 로컬 8001 기동 + LLM(NVIDIA NIM) 설정** → 프론트 로그인 → `/snap` 세션 목록이
실제 DB 세션으로 뜸.
2. **새 대화** → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후
사이드바 제목이 LLM 이 지은 제목으로 자동 변경(title 이벤트).
3. **기존 세션 재진입** → 과거 메시지 렌더 정상.
4. **생성 중 재전송** → 막힘 + toast.
5. 기존 프론트 테스트(`snap.api`/`snap.stream`/`sse`/`streamLLM`) 통과 + real 경로 유닛
테스트 추가(핸들러 매핑·title·409·Bearer 헤더 주입).
---
## 6. 리스크 / 열린 항목
- **title 갱신 위치**: react-query 캐시만 패치할지, snap 스토어에도 반영할지 —
사이드바가 `useSessionList` 캐시를 보면 캐시 패치로 충분. 구현 때 확정.
- **Bearer refresh**: .NET 호스트 부재로 이번엔 TODO. 호스트 붙을 때 별도 phase.
- **usage/subagent**: 지금 무시. 진행 UI 는 후속 spec 에서.
@@ -0,0 +1,59 @@
# 하이퍼워터폴 접목 설계 — spec-kit + superpowers 워크플로우 보강
- 날짜: 2026-07-27
- 출처: rhwp Hyper-Waterfall 방법론 정리 문서 (myVault topics/2026-07/0005)
- 결정 방식: brainstorming 스킬로 4문답 → 방안 A 승인
## 목적 (사용자 선택)
AI 페어프로그래밍의 두 약점을 문서로 막는다.
1. 세션 간 기억 유지 — 세션 끊겨도 "지금 뭐 하지 / 어디까지 / 왜 이렇게"가 0초에 복원
2. 방향 교정 게이트 — 틀린 방향으로 확신하며 달리는 걸 계획·완료 승인에서 잡음
3. 피드백·실패 자산화 — 사람 교정(feedback)과 실패 기록(troubleshootings) 영구 보존
4. 문서 체계 — 위 셋을 담는 최소 폴더 구조
## 결정 사항
| 항목 | 결정 |
|---|---|
| 앞단 | spec-kit 설치해서 결합 (하이퍼워터폴은 보강재, plan 집은 `specs/<feature>/` 하나 유지) |
| 승인 강도 | 계획(plan/tasks) + 완료(report)만 승인. 중간 단계는 보고서만 남기고 멈추지 않음 |
| 식별자 | spec-kit feature 이름. 이슈 번호·manifest 4필드는 스킵 (문서 늘어나 헷갈리면 그때) |
## 폴더 구조
```
specs/<feature>/ # spec-kit 표준 (spec.md, plan.md, tasks.md)
stage-N.md # [추가] 단계 보고: 한 일 / 검증 결과 / 다음
report.md # [추가] 완료 보고: 계획 vs 결과 + 달라진 점 + 남긴 것
docs/
orders/YYYYMMDD.md # [추가] 오늘 할 일 + 진행 중 feature 포인터 (ztodo.md 승격)
feedback/yyyy-mm-dd-<keyword>.md # [추가] 사람 교정 원문 + 왜 + 앞으로
tech/<topic>.md # [추가] 기술 사실 영구화 (topic 당 1파일, 갱신형)
troubleshootings/ # 기존 그대로
```
## 파이프라인 훅 5개
1. **세션 부트스트랩**: orders 최신 → 진행 중 feature 의 tasks.md + 최신 stage-N.md → 필요시 feedback/tech
2. **executing-plans 도중**: task 묶음 끝날 때마다 stage-N.md 기록, 승인 대기 없이 진행
3. **완료 시**: verification-before-completion → report.md → 사용자 최종 승인 → merge
4. **사용자 교정 시**: Claude 가 자발적으로 feedback/ 기록 (자동 트리거)
5. **기술 사실 발견 시**: tech/ 기록, plan 때 docs-lib 와 같이 참조
## 스킵한 것 (YAGNI)
- rhwp 이슈 번호 파일명 규칙 — 이슈·PR 안 쓰는 솔로 로컬 워크플로우
- manifest 4필드(kind/status/canonical/last_verified) — 문서 수십 개 넘어 캐논이 헷갈리면 도입
- PR 리뷰 라우팅·CI 게이트 — GitHub Actions 파이프라인 없음
## 도입 작업 (이 설계의 구현 범위)
1. spec-kit 설치 (`specify init --here --ai claude --script ps`)
2. `docs/orders`·`docs/feedback`·`docs/tech` 생성 + 각 README 양식
3. CLAUDE.md 갱신 (파이프라인 훅 + 부트스트랩 섹션)
4. ztodo.md 항목을 첫 orders 파일로 이사
도입 자체는 spec-kit 파이프라인 안 태움 (폴더 + 문서 편집뿐이라 너무 작음).
@@ -0,0 +1,82 @@
# 윈도우 데스크톱 런처 (WebView2 로 2_frontend 감싸기) — 설계
작성일: 2026-08-04
스코프: **B (껍데기 + 창 관리)** — raycast/wox/alfred 스타일 런처 셸
상태: 설계 승인됨 → 구현 계획(writing-plans) 대기
## 1. 뭘 만드나 / 왜
지금 `2_frontend/` React SPA 를 **윈도우 닷넷(WPF + WebView2)** 앱 안에 담아서, 전역 단축키로 소환하는 런처처럼 쓴다. 알맹이(챗·기능)는 지금 React 앱 그대로. 이번에 만드는 건 **껍데기(셸)** — 어떻게 뜨고 사라지고 상주하냐.
## 2. 핵심 전략 — V1 껍데기 재활용
`d:\project\021.code-assistant\3_windowsApp\` 에 V1 이 있음. 런처 껍데기(단축키·트레이·창관리)는 거기 다 만들어져 있어서 **그대로 이식**하고, "바뀐 로직"(닷넷이 챗·인증을 네이티브로 하던 부분)만 버린다. V2 는 React 가 `/api` 를 직접 부르니 그 네이티브 로직이 필요 없음.
| V1 요소 | V2 처리 |
|---|---|
| `CodeAssist.Shell` 전체 (HotKeyService·SingleInstanceGuard·ForegroundWindow·PasteService·ClipboardBackup·TrayIconHost·FramelessPaletteWindow·JsonWindowPlacementStore·ViteDevServer) | **그대로 이식** — 런처 원시기능, 로직 안 바뀜 |
| `App.xaml.cs` 뼈대 (단일인스턴스→핫키→포그라운드 캡처→팔레트 토글→트레이) | **이식** — V1 이 디버그용으로 주석 처리해둔 트레이·blur-to-hide 를 되살림 |
| WebView A/B 로딩 (DEBUG=vite, RELEASE=가상호스트 `SetVirtualHostNameToFolderMapping`) | **이식** — 소스 경로만 V1 의 `web/` → V2 의 `2_frontend/` 로 교체 |
| `Core.Chat`·`Core.Auth`·네이티브 `ChatView`/ViewModels·브릿지의 chat/auth/session 핸들러·Markdig | **버림** (지금은). V2 React 가 `/api` 직접 호출로 다 함 |
| 브릿지 `paste.code`·`window.hide`·`window.resize` | **v1 제외, 다음 단계** — 런처 감칠맛(직전 창에 붙여넣기·Esc숨김·자동높이). React 쪽 `postMessage` 한 줄 필요해서 v1 필수 아님 |
> **Core 는 나중에 참고할 것.** V1 의 `CodeAssist.Core`(Auth·Chat: AuthClient, DpapiTokenStore, SseChatClient, HttpSessionClient, 토큰 브릿지 등)는 지금은 안 쓰지만 버리는 게 아니라 **레퍼런스로 남긴다.** 나중에 (1) auth 브릿지를 되살려 C#↔JS 로 refresh 토큰 영속·자동 재인증을 붙이거나, (2) RELEASE `/api` 프록시를 네이티브로 처리할 때 이 코드가 출발점이 됨. 구현 시 V1 `Core` 를 열어보고 참고.
## 3. 프로젝트 구조 (V2)
```
2_frontend/ (그대로, 손 안 댐)
3_windowsApp/
CodeAssist.App/ WPF 진입점 + PaletteWindow + WebHostView (챗/인증 로직 뺀 WebChatView)
CodeAssist.Shell/ V1 그대로 이식 (재활용 핵심)
CodeAssist.Tests/ Shell 관련 테스트만 이식 (WindowPlacement, SingleInstance 등)
```
- **`Core` 프로젝트는 이번에 안 만든다** — 네이티브 챗/인증이 사라져 쓸 데 없음. auth 브릿지 되살릴 때 그때 V1 Core 참고해서 추가.
- 스택: .NET 8 (`net8.0-windows`) · WPF · WebView2 · (WPF-UI 는 창 스타일용, 옵션)
## 4. 동작 흐름
1. 부팅 → 트레이 상주 (단일 인스턴스 — 두 번째 실행은 기존 창 소환 후 종료)
2. `Ctrl+Alt+Space` → 직전 창 HWND 캡처 → 팔레트 Show (마지막 위치/크기 복원)
3. 창 안엔 **2_frontend React 앱** (DEBUG=vite 핫리로드 / RELEASE=dist 가상호스트)
4. `Esc` 또는 포커스 잃음(Deactivated) → Hide (위치·크기 저장)
5. 트레이 우클릭 → "종료" (평소 X 는 종료 대신 숨김)
## 5. 창·단축키 기본값 (V1 준용)
- 전역 단축키: **`Ctrl+Alt+Space`** (`Alt+Space` 시스템 메뉴 충돌 회피)
- 창: 테두리 있는 크기조절 가능한 일반 창, `CenterScreen` 최초 위치, 위치·크기 기억(`JsonWindowPlacementStore`)
- (다음 단계) web 이 알려준 컨텐츠 높이로 스르륵 리사이즈 — spotlight 느낌. 브릿지 `window.resize` 되살릴 때.
## 6. WebView 로딩 (A/B 둘 다 지원, 소스 주소만 분기)
V1 `WebChatView.OnLoaded` 패턴 그대로:
- **DEBUG**: `ViteDevServer``2_frontend``npm run dev` 를 띄우고 포트 대기 → `http://localhost:<port>` navigate. 핫리로드. `/api` 는 vite 가 백엔드로 프록시(지금 그대로).
- **RELEASE**: `2_frontend/dist` 를 출력 폴더로 복사 → `SetVirtualHostNameToFolderMapping("appassets.example", wwwroot, ...)``https://appassets.example/index.html` navigate.
- `UserDataFolder` 는 temp 로 명시(초기화 실패 방지), 초기화/네비 로그를 temp 파일에 남김 — V1 패턴 유지.
주의: 2_frontend 는 **BrowserRouter** 라 RELEASE 가상호스트에서 딥링크 새로고침 시 404 가능 → 필요하면 그때 HashRouter 전환이나 fallback 처리. v1(DEBUG 중심)에선 문제 없음.
## 7. 알려진 미결(다음 단계) — RELEASE `/api` 프록시
- **DEBUG(vite)**: vite 가 `/api` 프록시 → 지금 인증/쿠키 그대로 동작. **v1 은 여기까지 완전히 굴러감.**
- **RELEASE(가상호스트)**: React 가 `/api` 부르면 가상호스트 폴더에서 찾다 404. 닷넷이 `/api` 를 백엔드로 프록시해줘야 함(`WebResourceRequested` 가로채기 등). 근데 이게 **쿠키 origin·Entra 로그인과 엮여서 이번에 제외한 auth 스코프와 겹침.**
- **결정**: v1 = **DEBUG 모드로 런처 껍데기 완성·검증**(단축키·트레이·창관리·React 로딩 확인). RELEASE 패키징의 `/api` 프록시 + 쿠키 origin + Entra 로그인은 **auth 스코프 되살릴 때 함께 푸는 다음 단계.** (V1 `Core` 참고)
## 8. 이번 스코프에서 명시적으로 제외
- Entra/MSAL 로그인 데스크톱 처리 (Q3 제외됨)
- 런처 전용 신규 UI (C안 — 나중에)
- 브릿지 paste/hide/resize (다음 단계)
- RELEASE 패키징의 `/api` 프록시 (7번, auth 와 함께)
## 9. 성공 기준 (v1)
- [ ] `Ctrl+Alt+Space` 로 창이 뜨고/사라진다
- [ ] 트레이 상주 + 우클릭 종료, X 는 숨김
- [ ] 단일 인스턴스 (두 번째 실행이 기존 창 소환)
- [ ] 창 위치·크기가 기억된다
- [ ] 창 안에 2_frontend React 앱이 뜬다 (DEBUG=vite 핫리로드, `/api` 정상)
- [ ] Shell 이식분 테스트 통과 (WindowPlacement, SingleInstance 등)