Initial Commit
This commit is contained in:
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 등)
|
||||
Reference in New Issue
Block a user