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` — 태스크 간 시그니처 일치 확인함.
|
||||
Reference in New Issue
Block a user