Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# 밝은 테마 목업
- 파일: `light-palette-preview.html` (브라우저로 직접 열기, 외부 서버·의존성 불필요)
- 사용자 요청: 색 조합을 밝고 깔끔하게 개선하되, 앱 적용 전에 HTML 디자인 확인.
- 방향: 흰 작업 영역, 옅은 회색 사이드 영역, 선명한 본문. 청록·블루·차콜 3안 비교.
- 목업 범위: 기존 Snap 화면의 제목줄·코드 탐색·클립보드·대화·입력창을 참고한 독립 HTML.
- 동작: 팔레트 전환, 대화/새 대화/목록 전환, 좁은 창, 예시 검색, 입력 미리보기, 코드 복사. 파일은 이름만 표시하며 서버 전송 없음.
- 검증: Playwright + 설치된 Edge로 3개 팔레트, 화면 전환, 예시 입력/전송, 검색과 빈 결과, 좁은 창의 레일 숨김, 390px 가로 넘침 확인. 데스크톱·좁은 창·모바일 캡처 검토.
- 최종 선택: 클린 블루, 사용자 지정 포인트 `#034EA2`. HTML은 이 조합으로 시작함.
- 실제 적용: `clean-blue` 팔레트 추가 및 기본값 지정. 흰 배경, 옅은 블루 그레이 표면, `#034EA2` 버튼·선택 글자·포커스. 신규 모드는 light이며 명시적으로 저장한 다른 팔레트/다크 설정은 유지.
- 이전 기본값: 저장된 `warm-tan`은 첫 로드에 한 번만 클린 블루로 전환. 그 뒤 다시 고른 warm-tan은 보존.
- 앱 검증: 빌드, 변경 TS 파일 ESLint, 기존 테마 테스트 5개 통과. 빌드 결과를 Edge에서 열어 신규 기본값·기존 기본값 전환·다른 팔레트 유지·warm-tan 재선택 보존 확인. 로그인 화면과 HTML 목업 캡처 검토. 인증 후 실제 대화 기능은 이번 색상 검증 범위에 포함하지 않음.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
# 2026-07-27 · worklog 에 git 작업 적지 말 것
## 사용자 원문
> 아 git 관련된 작업은 worklog에 적지 말아야해.
## 뭐가 틀렸나
worklog 마커 자동수집을 붙이고 테스트하던 중, tsv 에 "worklog 파일 전부 git 반영 - … 커밋 2건", "스냅 UI 변경 … 커밋", "renormalize … 커밋 없이 해소" 같은 **형상관리(commit·push·renormalize) 자체를 스텝으로 기록**함. worklog 는 "무엇을 만들었나(재현·수정·검증)"를 남기는 건데, "그걸 커밋했다"는 git 히스토리에 이미 있어서 중복이고 노이즈였음.
## 앞으로
- worklog 마커·수동 add 에 **git 동작(commit/push/branch/merge/add/renormalize 등)은 넣지 않는다.** 만든 결과물만 남기고 커밋했다는 사실은 뺀다.
- CLAUDE.md 5번에 규칙으로 박음(캐논).
## 곁가지 (같이 드러난 진짜 버그)
마커 스캐너가 정규식으로 transcript 생줄을 긁어서, 문서·selftest 코드·툴 출력에 **예시로 쓴 마커 문법까지 실제 로그로** 넣어버렸음(스텝A·옛줄·끊김 재현… 등). → transcript JSON 을 파싱해 **assistant 의 text 블록만** 스캔하도록 고침. tool 입력·파일 출력·thinking 은 제외.
@@ -0,0 +1,14 @@
# UI 테스트는 사용자가 직접 진행
## 사용자 원문
> 잠깐, COMPUTER USE 쓰지마
> 내가 테스트함
## 교정 내용
Tauri 첫 실행 확인 중 Computer Use로 창 상태를 조회했으나, 사용자는 직접 UI를 테스트하길 원함.
## 앞으로
이 작업에서는 Computer Use를 사용하지 않음. 자동 검사와 코드·문서 점검만 진행하고, UI 확인은 사용자 결과를 받은 뒤 완료 처리함.
@@ -0,0 +1,18 @@
# Tauri를 기준 앱으로 전환
## 사용자 원문
> rust tauri 버전으로 정의하자
>
> 러스트 타우리 버전으로 수정 시작하자
## 뭐가 틀렸었나
기존 `004-tauri-shell`은 Tauri와 .NET을 끝까지 비교한 뒤 선택하는 실험으로 정의돼 있었다. 사용자는 이미 Tauri를 기준 앱으로 정했으므로 동시 실행, 별도 단축키, 설치물 비교, 중간 중단 계획은 더 이상 목적에 맞지 않는다.
## 앞으로 어떻게 할까
- `4_rust_tauri`를 기본 실행·배포·새 데스크톱 기능 대상으로 삼는다.
- `3_windowsApp`은 삭제하지 않고 참고용 legacy로만 남긴다.
- Tauri가 표준 단축키 `Ctrl+Shift+7/8/9`를 사용한다.
- US1~US5를 전부 옮기고 A/B 비교용 작업은 제거한다.
@@ -0,0 +1,13 @@
# Snap 창에는 사이드바가 없음
## 사용자 피드백
> 어디 있다는거야? CTRL +B 도 안돼
## 왜 틀렸는지
사용자가 보고 있는 화면을 확인하지 않고 일반 대시보드의 `AppSidebar` 기준으로 안내했음. 실제 화면은 사이드바가 없는 `/snap` 전용 창이라 `Ctrl+B`도 적용되지 않음.
## 앞으로 어떻게 할지
창 종류가 여러 개인 앱에서 UI 위치를 안내할 때는 현재 route와 실제 스크린샷을 먼저 확인함. 공통 기능은 해당 창에서 직접 접근할 수 있는지도 함께 확인함.
+6
View File
@@ -0,0 +1,6 @@
# feedback — 사람이 교정해준 순간 보존
- 파일명: `yyyy-mm-dd-<keyword>.md`, 1교정 = 1파일.
- 내용: 사용자가 한 말 원문 그대로 + 뭐가 틀렸었는지 + 앞으로 어떻게 할지.
- 사용자가 방향 잡아줄 때마다 Claude 가 자발적으로 기록. 같은 실수 반복 방지가 목적.
- rhwp 하이퍼워터폴에서 "AI 가 못 만드는 문서 = 가장 값진 자산"이라 부른 그 폴더.
+6
View File
@@ -0,0 +1,6 @@
# 2026-07-27
진행 중 feature: 없음 (하이퍼워터폴 워크플로우 도입 작업 중)
- [ ] 토큰 수, 토큰 비용
- [x] 하이퍼워터폴 문서 체계 도입 (spec-kit 설치 + docs 폴더 + CLAUDE.md 갱신)
+36
View File
@@ -0,0 +1,36 @@
# 2026-08-11
진행 중 feature: **`specs/004-tauri-shell/`** — Tauri 데스크톱 껍데기 (A/B 비교판)
## 어디까지 했나
spec → plan → Phase 0/1 산출물까지. **`tasks.md` 아직 없음** (다음 할 일).
- [x] `/speckit-specify``spec.md` (US1~US5, 미결 2건 D1·D2 로 확정)
- [x] `/speckit-plan``plan.md` + `research.md`(결정 12건) + `data-model.md` + `contracts/transport-mapping.md` + `quickstart.md`
- [ ] `/speckit-tasks``tasks.md`**여기부터**
- [ ] US1 구현 (프론트 transport seam) — rust 없이도 가능
- [ ] rustup 설치 승인 → US2~US5
## 다시 시작할 때 읽을 순서
1. `specs/004-tauri-shell/spec.md` 의 "정해진 것" D1·D2 — 왜 정성 평가인지, 왜 범위가 US5까지인지
2. `specs/004-tauri-shell/research.md` R1·R2·R3 — 환경·기존 스캐폴드·docs-lib
3. `specs/004-tauri-shell/quickstart.md` A단계 — 회귀 안 내는 법
## 막혀 있는 것 (사람 결정 필요)
- [ ] **rustup + VS Build Tools 설치** (2~3GB, MSVC 툴체인). `research.md` R1 에 근거 정리됨.
US1(프론트)은 이거 없이 진행되고, **US2부터 막힘**.
## 알아둘 상태
- `4_rust_tauri/`**반쯤 만들다 만 스캐폴드**가 있고 **지금 빌드 안 됨**
(`Cargo.toml` lib 이름 `codeassist_tauri_lib``main.rs` 는 생성기 기본값 `_rust_tauri_lib` 호출).
처리 방침은 `research.md` R2 — 아이콘·`build.rs`·`capabilities` 는 신뢰, **손으로 쓴 Rust 는 컴파일 검증 0이라 전부 재검증 대상**.
- Tauri v2 API 는 **기억으로 쓰지 말 것.** 첫 Rust 코드 전에 `4_rust_tauri/docs-lib/tauri-v2.md` 부터 만든다 (`research.md` R3, CLAUDE.md §3).
- `3_windowsApp`(.NET)은 **비교 기준선이라 무변경**.
## 덜 급한 것
- [ ] `specs/003-token-cost/` — spec 초안만 있고 plan 부터 안 됨
+47
View File
@@ -0,0 +1,47 @@
# 2026-08-15
진행 중 feature: **`specs/004-tauri-shell/`** — Tauri 데스크톱 껍데기 (A/B 비교판)
## 오늘 한 것
- [x] **rust 툴체인 설치** (T001) — 8/11 부터 막혀 있던 사람 결정 항목. 승인받고 진행
- `rustup` + `stable-x86_64-pc-windows-msvc` (rustc/cargo 1.97.1)
- MSVC 는 **별도 Build Tools 를 새로 안 받고** 이미 있던 **VS 2026 Community 에 컴포넌트만 추가**`VC.Tools.x86.x64`(14.51.36231) + `Windows11SDK.26100`(10.0.26100.0). 중복 설치 2~3GB 아낌
- `rustc` 로 실제 링크까지 되는 것 확인 (버전만 보고 넘기지 않음)
- [x] **`/speckit-tasks``tasks.md`** — 69개 task, 8 phase. spec→plan→tasks 문서 단계 끝
- [x] **T002~T005 (Phase 1 나머지) — docs-lib 박제 + 의존성**
- `4_rust_tauri/docs-lib/``README.md` + `tauri-v2.md`(API 박제) + `tauri-v2-llms-full.md`(공식 가이드 원문 2.4MB)
- `2_frontend/docs-lib/tauri-api.md``invoke`/`listen` 표면
- `2_frontend``@tauri-apps/api` `^2.11.1` 설치 → 빌드 통과 확인
- **Phase 1 전부 끝. 이제 Rust 코드 써도 됨**
### 박제하다 계획이 바뀐 것 3건 (중요)
공식 가이드를 안 믿고 **`cargo fetch` 로 받은 실제 crate 소스**에서 뽑았더니 나온 것들. (가이드 안에 `global-shortcut` 핸들러가 2인자/3인자로 둘 다 있어서 시그니처 근거로 못 씀 — R3 이 경고한 함정이 공식 문서에 그대로 있었다.)
1. **DPI 인지는 할 일이 없어졌다** (T049) — `tao` 가 이벤트 루프 만들 때 `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` 를 무조건 부른다. R11 의 "매니페스트 넣기"가 통째로 사라짐. **US4 에서 좌표 어긋나면 원인이 DPI 가 아니라 좌표 변환 쪽**
2. **호스트 판별식을 바꿔야 한다** (T012·T015) — `"__TAURI_INTERNALS__" in window` 는 틀린다. 공식 `clearMocks()` 가 속성만 지우고 **빈 객체를 남겨서** 브라우저 모드 테스트가 tauri 로 오판됨 → `typeof window.__TAURI_INTERNALS__?.invoke === "function"` 으로 잰다
3. **window-state 기본 플래그가 함정** (T030) — `StateFlags` 기본값이 `all()` 이라 `VISIBLE`·`DECORATIONS` 까지 복원한다. 트레이 상주 + 프레임리스 앱엔 독 → `SIZE | POSITION` 만 켠다
**공식 테스트 mock 도 찾음**`@tauri-apps/api/mocks``mockIPC`/`clearMocks`(+`shouldMockEvents``listen` 까지). T012~T014 에서 직접 흉내낼 필요 없음.
## 다음 할 일
- [ ] **Phase 2 (T006~T011)** ← 여기부터 — 반쯤 만든 스캐폴드 재검증하고 `npm run tauri dev` 첫 통과
- T006 `main.rs` lib 이름 정합이 첫 줄
- `Cargo.toml` 은 이미 봐뒀는데 멀쩡함(`cargo generate-lockfile`·`cargo fetch` 통과, 488 패키지 잠김)
- [ ] Phase 3 = **US1 (MVP)** — 프론트 transport seam. Phase 2 와 **병렬 가능** (rust 없이도 됨)
## 알아둘 상태
- `4_rust_tauri/` 는 아직 **커밋 안 된 반쯤 만든 스캐폴드**고 **빌드 안 됨**
(`Cargo.toml` lib 이름 `codeassist_tauri_lib``main.rs``_rust_tauri_lib` 호출 — T006 이 이거)
손으로 쓴 Rust 는 **컴파일 검증 0줄**이라 전부 재검증 대상 (research R2)
- Tauri v2 API 는 **기억으로 쓰지 말 것** — T003 의 docs-lib 박제 후 그걸 보고 짬
- **US1 에서 가장 위험한 자리**: 브릿지 테스트 3개(`bridgeNavigate.test.ts`·`snippetBridge.test.ts`·`snippets.api.test.ts`, 약 240줄)가 `window.chrome.webview`**테스트 본문에서 런타임에 심는다.** research R4 의 "모듈 로드 때 호스트 고정" 과 정면 충돌 — T015 에서 판별 시점을 먼저 정하고 테스트를 써야 함
- `3_windowsApp`(.NET)은 **비교 기준선이라 무변경**
## 덜 급한 것
- [ ] `specs/003-token-cost/` — spec 초안만 있고 plan 부터 안 됨
+60
View File
@@ -0,0 +1,60 @@
# 2026-09-09
진행 중 feature: **`specs/004-tauri-shell/`** — Tauri 기준 데스크톱 앱 전환
## 오늘 할 일
- [x] Tauri를 기본 실행·배포 대상으로 정함
- [x] 기존 .NET 앱을 참고용 legacy로 남기기로 정함
- [x] feature spec과 품질 체크리스트를 새 방향으로 수정
- [x] 수정된 spec 사용자 검토
- [x] plan·tasks를 Tauri 기준으로 다시 맞춤
- [x] Phase 2 빌드 베이스라인(T006~T011) 실행
- [x] Phase 3 프론트 transport 전환(T012~T025) 완료
- [x] Phase 4 Tauri shell 기준 확정(T026~T040) 완료
## 결정
- Tauri가 표준 단축키 `Ctrl+Shift+7/8/9`를 사용함
- US1~US5를 전부 구현함
- A/B 동시 실행·설치물 비교·중간 중단은 범위에서 뺌
## 같은 날 main에서 진행한 작업 기록
아래는 당시 기록이며, 통합 후 현재 상태는 `20260911.md`와 feature tasks를 따름.
진행 중 feature: **`specs/004-tauri-shell/`** — Tauri 데스크톱 껍데기
## 오늘 한 것
- [x] 닷넷과 Tauri 단축키 대조. Tauri 조합을 Ctrl+Shift+7/8/9로 통일하고 화면 이동 수신·route 보고·Esc 숨김 연결 수정. 상세: `docs/troubleshootings/2026-09-09-tauri-shortcuts.md`.
- [x] 밝은 테마 HTML 목업 3안 제작. 사용자 최종 선택인 클린 블루에 지정 색상 `#034EA2` 적용.
- [x] 실제 `clean-blue` 팔레트·라이트 기본값 적용, 이전 기본 베이지 한 번 전환. 빌드·변경 TS lint·테마 테스트 5개 및 브라우저 색상/저장 설정 확인 통과. 상세: `docs/design/README.md`.
- [x] Phase 2 T006~T010 실제 코드·설정 대조 후 완료 처리. 코드 변경 없이 기존 구현 확인.
- [x] `npm run tauri dev` Rust 빌드·실행 통과. 사용자가 테스트하도록 앱과 Vite 서버 실행 유지.
- [x] `cargo test --locked` 통과. 현재 Rust 테스트는 0개이므로 기능 검증을 뜻하지 않음.
- [x] 프론트 테스트 42파일·238개 통과, 프로덕션 빌드 통과.
- [x] 프론트 lint 실행: 기존 오류 10개·경고 10개 확인. FileDropZone 접근성 오류 3개, skill-mapping의 any 타입 오류 7개. 이번에 프론트 소스는 수정하지 않음.
## 다음 할 일
### 추가 요청: 창 닫기 버튼
- [x] 추가 요청에 따라 Tauri 메인 창 시작 URL을 `/snap`으로 고정. 개발 서버·배포 앱 모두 같은 창 설정을 사용함. 미인증 시 기존 로그인 후 `/snap` 복귀 동작 유지.
- [x] Tauri 전 화면에 공통 제목줄과 우측 닫기 버튼 추가. 기존 Rust `window_hide`를 호출해 트레이로 숨김. 제목줄 드래그는 `window_drag` 재사용.
- [x] 수정 파일 Prettier·ESLint 및 프론트 빌드 통과. 전체 lint의 기존 오류는 그대로 남아 있음.
- [ ] 사용자 직접 확인: 닫기 클릭 시 숨김, 트레이 열기로 복귀, 제목줄 드래그와 화면 높이 확인.
### 기존 진행 순서
- [ ] T011: 사용자 직접 테스트 결과로 창 표시·프론트 화면 확인 후 완료 처리. 사용자 요청으로 Computer Use 중단.
- [ ] Phase 3 US1: 프론트 transport 구현 및 회귀 검증.
- [ ] 기존 프론트 lint 오류 해결 — Phase 3 자동 검사 통과 전에 필요.
## 실행 중 참고
- 백엔드 미실행으로 `/api/v1/files` 요청에 `ECONNREFUSED` 발생. 화면 표시 확인과 실제 API 기능 확인을 구분할 것.
- 이전 기록의 main.rs 이름 불일치와 미커밋 설명은 현재 상태와 다름. 실제 lib 이름은 일치하고 스캐폴드는 이미 커밋돼 있음.
+23
View File
@@ -0,0 +1,23 @@
# 2026-09-11
진행 중 feature: **`specs/004-tauri-shell/`** — Tauri 기준 데스크톱 앱 전환
## 사용자 요청
- Raycast 형태 스니펫 개선 승인: 검색창만 시작 → 검색 결과 → 직접 선택 시 코드 프리뷰. 실제 Tauri 창 크기 제어까지 포함.
- `urchin`과 같은 방식으로 `AGENTS.md`를 만들고 `CLAUDE.md`를 작업 규칙 원본으로 지정.
- `tauri-canonical` 작업을 현재 main 기준으로 합친 뒤 해당 작업 폴더와 브랜치를 제거.
## 통합 기준
- 현재 main의 클린 블루, `/snap` 시작 경로, 닫기 버튼, 스니펫 DB 읽기, Enter 복사를 보존.
- canonical의 공통 transport, Win32 붙여넣기, 창 상태 저장 범위, Tauri 기준 명세를 반영.
- main의 실패 토스트와 WebView2·Windows Tauri 회귀 검사를 공통 transport에서도 유지.
- `4_rust_tauri`가 기준 앱이고 `3_windowsApp`은 참고용 legacy로 유지.
- 이전 양쪽 작업은 커밋 이력으로 보존. 9월 9일 작업로그는 시간순으로 합침.
## 다음
- 단계별 스니펫 화면과 창 크기 전환 구현. 검사 결과와 미완료 저장 기능은 `specs/001-snippet-palette/tasks.md`에서 추적.
- 붙여넣기는 코드·컴파일 검사와 실제 대상 앱 검증을 구분. 이번 통합에서 다른 앱에 키 입력을 주입하지 않음.
- 스니펫 쓰기·사용기록 저장과 화면 캡처는 여전히 미구현. 기존 feature의 미완료 항목을 계속 추적.
+17
View File
@@ -0,0 +1,17 @@
# 2026-09-16
진행 중 feature: **`specs/005-chat-image-input/`** — 채팅 이미지 입력
## 사용자 요청
- base-backend의 새 이미지 채팅 계약에 맞춰 화면 캡쳐와 클립보드 이미지를 전송함.
## 완료
- 새 대화와 기존 대화에서 PNG/JPEG/WebP 이미지를 질문에 첨부함.
- 이미지-only 전송과 서버 동일 제한을 적용함.
- 전체 frontend 검사 통과.
## 다음
- 실제 backend 환경에서 vision 응답을 수동 확인함.
+5
View File
@@ -0,0 +1,5 @@
# orders — 오늘 할 일
- 파일명: `YYYYMMDD.md`, 하루 1파일.
- 내용: 체크박스 할 일 + 진행 중 feature 포인터(`specs/<feature>/`).
- 세션 부트스트랩 때 최신 파일부터 읽는다. (ztodo.md 승격판 — ztodo.md 는 더 이상 안 씀)
+18
View File
@@ -0,0 +1,18 @@
# SAP ERP 개발을 위한 **CodeAssist Everywhere**
> **Instant Search와 AI Chatbot을 하나로 묶어, SAP GUI와 Eclipse(ADT) 어디서든 필요한 코드와 답을 즉시 찾아 바로 활용함**
| Instant Search | AI Chatbot |
|---|---|
| 자주 쓰는 ABAP 코드·템플릿을 입력 즉시 검색 | ABAP·CDS View·HANA·에러 로그를 대화형으로 분석 |
| 사용 빈도와 최근 사용 이력 기반으로 우선 노출 | 코드 작성·설명·오류 원인·개선안까지 실시간 답변 |
| 선택한 코드를 기존 작업 창에 바로 붙여넣기 | 개발 중인 코드나 로그를 붙여넣고 흐름을 끊지 않고 질문 |
### 개발 흐름
**SAP GUI / Eclipse(ADT)에서 개발****전역 단축키로 CodeAssist 호출****코드 검색 또는 AI 질문****결과를 원래 개발 화면에 즉시 적용**
### 기대 효과
**반복 코드 탐색 시간 단축 · 문제 해결 속도 향상 · 개발 지식의 재사용 · 도구 전환 최소화**
File diff suppressed because it is too large Load Diff
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 등)
+5
View File
@@ -0,0 +1,5 @@
# tech — 기술 사실 영구화
- 파일명: `<topic>.md`, topic 당 1파일. 새 사실 발견하면 새 파일 말고 그 파일 갱신.
- 내용: 작업 중 발견한 이 코드베이스·연동 백엔드 관련 기술 사실 (예: SSE 재연결 동작, react-query 캐시 규칙).
- plan 짤 때 docs-lib 와 함께 참조 대상.
+139
View File
@@ -0,0 +1,139 @@
# CodeAssist 백엔드를 OpenCode + Django 로 — 분석과 인프라 제안
작성 2026-09-16. 코드 변경 전 분석 단계. 확정되면 `/speckit-specify``specs/006-*` 파고 여기 내용은 research 로 승계.
## 0. 한 줄 결론
**프론트 계약은 그대로 두고, ABAP_OPENCODE 의 Django(`web/BE`) 안에 CodeAssist 전용 어댑터 앱을 하나 얹는다.**
Django 가 OpenCode 서버를 호출하고, OpenCode 의 이벤트 스트림을 프론트가 이미 아는 `POST /chat/stream` SSE(token/title/usage/done/error)로 바꿔준다.
프론트 변경은 env 한 줄 + 토큰 저장 두 줄. 나머지는 전부 서버 쪽.
## 1. 지금 상태 — 프론트가 백엔드에 기대하는 것
출처: `2_frontend/src/lib/api/client.ts`, `lib/streaming/streamLLM.ts`, `features/snap/api/*`, `features/auth/api/auth.api.ts`, `types/api.ts`, `specs/005-*/contracts/chat-stream.md`, `docs/superpowers/specs/2026-07-18-snap-backend-connect-design.md`.
현재 백엔드는 별도 repo 의 FastAPI(base-backend, `localhost:8001`). 이 머신엔 없음.
| 영역 | 프론트가 기대하는 것 |
|---|---|
| 베이스 | `VITE_API_BASE_URL` (기본 `/api/v1`, 풀 URL 도 됨). dev 는 Vite proxy 로 same-origin |
| 응답 모양 | 전부 envelope `CommonResponse{success,statusCode,code,message,data,counts,errors,timestamp,meta}`. 헬퍼가 `.data` 언랩, `apiList``meta{currentPage,pageSize,totalItems,totalPages,hasNextPage,hasPreviousPage}` 로 페이지네이션. `success:false``ApiError` |
| 인증 | httpOnly 쿠키(`accessToken`/`refreshToken`) + 401 → `POST /auth/refresh` 자동 재시도. **Bearer seam 이미 있음**`lib/auth/tokenProvider.ts` 에 토큰 넣으면 axios·SSE 둘 다 `Authorization: Bearer` 로 감 |
| 인증 API | `POST /auth/login{email,password}``TokenResponse{token,refreshToken,…,user}` (프론트는 `.user` 만 씀) · `POST /auth/refresh` · `POST /auth/logout` · `GET /users/me` · Entra `POST /auth/entra/login`, `GET /auth/entra/config` (**501 이면 버튼 숨김**) |
| 세션 | `GET /chat/sessions?page&limit` (page=3개 peek) · `POST /chat/sessions` · `GET /chat/sessions/search?query&page&limit` (메시지 본문 ILIKE) · `GET /chat/sessions/{id}/messages` (`isGenerating` true 면 1.5초 폴링) · `POST /chat/sessions/{id}/cancel` |
| DTO | `SnapSession{id,title,titleLlm,isGenerating,createdAt,updatedAt}` · `SnapMessage{sessionId,role,content,createdAt,inputTokens?,outputTokens?,costUsd?,elapsedMs?}` |
| 스트림 | `POST /chat/stream` body `{sessionId,content,images?[{mediaType,data(dataURL)}],forcedSkill?,explain?}`. 요청 하나 = SSE 하나. 이벤트 `token{delta}``usage{used,limit,ratio,elapsed_ms}``done{traceId?}`, 중간 `title{title}`, 실패는 `error{message,code}`. `@microsoft/fetch-event-source` 라 POST 가능 |
| 스니펫 | **백엔드 안 씀.** Tauri 브릿지 → 로컬 SQLite |
**패키징 gotcha (백엔드 뭘 쓰든 동일):** Tauri 빌드는 dist 를 `tauri://` 계열 origin 으로 띄움. `VITE_API_BASE_URL=/api/v1` 상대경로는 dev(Vite proxy)에서만 되고, 배포본은 **풀 URL** 이어야 함. 그러면 cross-origin 이라 쿠키는 `SameSite=None; Secure` = **https 필수**. 고객사 bare 배포가 http(`10.196.81.34:8918`)라 쿠키 인증은 거기서 깨짐 → Bearer 로 가는 게 안전.
## 2. 가져다 쓸 것 — ABAP_OPENCODE `web/BE` Django 가 이미 하는 것
출처: `web/BE/code/README.md`, `common/opencode_service.py`, `apps/chat/sse.py`, `apps/accounts/authentication.py`, `deploy/docker-compose.yml`, `apps_ito/aaf/urls.py`.
| 영역 | 있는 것 | CodeAssist 계약과 차이 |
|---|---|---|
| 런타임 | Django + DRF, **uvicorn(ASGI)** — SSE 버퍼링 없이 흐름 | 같음. 그대로 |
| 인증 | simplejwt **Bearer 헤더 또는 `?token=`**, 14일 액세스, refresh 없음, 쿠키 없음, `{"detail"}` 에러 | envelope 아님, 쿠키 아님, refresh 없음 |
| 세션 | OpenCode 가 세션·메시지 원본. Django 는 `session_owner` 매핑만 | 페이지네이션 없음, 검색 없음, `isGenerating`·usage 필드 없음 |
| 스트림 | `GET /chat/events` **전역 SSE 하나**(OpenCode `/event` 를 owner 필터해 통째로 중계). 전송은 `POST …/messages` 동기(최대 10분 블록). FE 가 `message.part.delta`/`session.idle` 직접 파싱 | 요청-단위 SSE 아님, 이벤트 어휘 다름 |
| OpenCode | `opencode serve :4096`, workspace = `AGENTS.md` + `skills/` + `opencode.json`(모델·MCP). `.opencode/agent/*.md` 로 에이전트 정의 | AGENTS.md 가 "ABAP FS 브레인스토밍 자동 트리거" — CodeAssist 짧은 코드 답변과 성격 충돌 |
| LLM 연결 | opencode.json → OpenRouter GLM. 고객사는 `render-opencode.py` 가 FabriX 모델 목록으로 렌더하고 `/api/ito` passthrough 게이트웨이가 FabriX 로 중계(401 4조합 재시도까지 됨) | 그대로 재사용. 폐쇄망 대응 다 돼 있음 |
| SAP | `sap-icf` MCP(:3200), 고객사 ZAI 모드 자동 | CodeAssist 챗도 "이 테이블 뭐야" 같은 질문에 쓸 수 있음 — 덤 |
| 배포 | compose(fe·db·be·be-watcher·opencode·mcp) + 고객사용 bare `run.sh` | 컨테이너/프로세스 하나 추가하면 됨 |
| DB | SQLite ↔ PostgreSQL 스위치, `DB_HOST` 로 자동 판단 | 그대로 |
## 3. 선택지
| | A. Django 에 CodeAssist 어댑터 앱 추가 (추천) | B. 프론트를 ABAP_OPENCODE 계약으로 갈아타기 | C. OpenCode 안 거치고 `/api/ito` OpenAI 호환 중계만 |
|---|---|---|---|
| 프론트 변경 | env + 토큰 저장 2줄 | envelope·인증·스트림 모델 전부 (snap feature 대수술) | 스트림 어댑터 새로 |
| 서버 변경 | 앱 1개 + 어댑터 | 거의 없음 | 세션·히스토리·검색 전부 Django 가 직접 |
| OpenCode 장점(도구·MCP·스킬·세션 컨텍스트) | 씀 | 씀 | **못 씀** — "opencode + django" 요청과 어긋남 |
| 위험 | 이벤트 어댑터 품질 | 회귀 범위 큼(테스트 268개 건드림) | 기능은 단순하지만 요청 취지 밖 |
**A 로 간다.** 프론트는 이미 "seam 만 바꾸면 되게" 짜여 있고(2026-07-18 설계의 방침 그대로), Django 쪽은 owner 매핑·OpenCode 클라이언트·SSE 파서가 다 있어서 어댑터만 얹으면 됨.
## 4. 인프라 그림 (제안)
```
[Windows PC] [서버 VM — ABAP_OPENCODE 스택 + 1]
CodeAssist.exe (Tauri) ┌─ nginx(:80) 또는 Django 직접(bare :8080)
├─ WebView2: React dist │ /base-api/* → web/BE (기존 ABAP OpenCode 화면용)
│ VITE_API_BASE_URL=https://<서버>/ca-api/v1 │ /ca-api/v1/* → web/BE apps/codeassist ← 신규
│ Authorization: Bearer <JWT> ───────────┼──▶ Django(uvicorn ASGI, :8100)
│ │ ├─ apps/accounts JWT 발급 (공용)
│ │ ├─ apps/codeassist 세션·검색·스트림 어댑터 + 미러 DB
│ │ └─ apps_ito/aaf /api/ito OpenAI 호환 중계 (FabriX/GLM)
└─ Rust: 핫키·캡처·스니펫 SQLite(로컬, 서버 X) │
├─ opencode-ca(:4097) ← 신규 컨테이너. 같은 이미지, 다른 workspace
│ /workspace-ca/AGENTS.md (짧은 코드 답변 규칙)
│ /workspace-ca/opencode.json (모델 = /api/ito 게이트웨이 또는 OpenRouter, MCP = sap-icf)
│ /workspace-ca/.opencode/agent/codeassist.md
├─ opencode(:4096) 기존 ABAP OpenCode 용 (안 건드림)
├─ mcp(:3200) sap-icf 둘이 공유
└─ db SQLite 또는 PostgreSQL (공유, 테이블만 추가)
```
**왜 OpenCode 를 따로 하나 더 띄우나:** OpenCode 는 workspace 의 `AGENTS.md` 를 모든 세션에 적용함. ABAP OpenCode 것은 "리포트 만들어줘 → 코드 쓰지 말고 질문 시작" 규칙이라 CodeAssist(즉답 코드 도우미)랑 정면 충돌. 이미지·볼륨만 다르게 하면 비용 거의 0. 포트 하나(4097)만 더.
**왜 Django 는 하나:** 인증(accounts)·ITO 게이트웨이(FabriX)·DB·배포 스크립트를 두 번 안 만들려고. CodeAssist 라우트는 `/ca-api/v1/` 프리픽스로 분리해서 기존 `/api/v1/chat/*` 랑 안 겹치게.
## 5. 어댑터 앱 `apps/codeassist` — 뭘 만드나
### 5.1 응답 모양
- DRF Renderer + exception handler 를 **이 앱에만** 걸어 `CommonResponse` envelope 로 감쌈. 목록은 `meta` 채움.
- 에러 코드는 프론트가 아는 것 유지: `CHAT_GENERATION_IN_PROGRESS`(409), `LLM_ERROR`, `REFRESH_TOKEN_MISSING` 등.
### 5.2 인증
- `POST /ca-api/v1/auth/login` → 기존 accounts 로그인 재사용, 응답만 `TokenResponse` 모양. `refreshToken` 은 simplejwt refresh 토큰.
- `POST /auth/refresh` 는 body 로 refreshToken 받는 걸로(쿠키 아님). `GET /users/me` 는 accounts `me` 감싸기.
- Entra: `GET /auth/entra/config`**501** 로 시작 (프론트가 버튼 숨김). 필요해지면 2차.
- **프론트 변경 지점 (유일):** 로그인 응답의 `token``setAccessToken()` 에 넣고 저장. 저장소는 Tauri 쪽(`tauri-plugin-store` 또는 Windows Credential Manager) 이 정석, 1차는 localStorage 로 시작해도 됨. 401 인터셉터의 refresh 호출을 Bearer 모드에서도 돌게 하는 것까지 포함(지금은 `// TODO(host)`).
### 5.3 세션·메시지 — 미러 테이블
OpenCode 는 페이지네이션·본문 검색·`isGenerating`·메시지별 usage 를 안 줌. 그래서 Django 에 **미러**를 둠(base-backend 도 DB 가 원본이었음):
- `ca_session(id=opencode session id, user, title, title_llm, is_generating, created, updated)`
- `ca_message(session, role, content, created, input_tokens, output_tokens, cost_usd, elapsed_ms)`
- 쓰는 시점: 스트림 시작 시 user 메시지 insert + `is_generating=true`, `session.idle` 받으면 OpenCode `GET /session/{id}/message` 로 마지막 assistant 메시지·`tokens`/`cost` 가져와 insert, `is_generating=false`.
- 검색은 미러에서 `icontains`. 목록은 미러에서 `-updated` 정렬 + page/limit.
- 대화 컨텍스트 원본은 계속 OpenCode(세션 이어서 질문하면 OpenCode 가 히스토리 들고 있음). 미러는 조회·검색 전용.
### 5.4 스트림 어댑터 `POST /chat/stream`
순수 Django async 뷰(ASGI). 흐름:
1. owner 확인, `is_generating` 이면 409 `CHAT_GENERATION_IN_PROGRESS`.
2. 프로세스당 하나인 **OpenCode `/event` 구독 태스크**(기존 `iter_event_jsons` 재사용)에서 세션별 `asyncio.Queue` 로 fan-out.
3. OpenCode 에 메시지 전송은 **블록 안 되게** 스레드/태스크로 (`sync_to_async(send_message)` 또는 OpenCode 의 비동기 프롬프트 엔드포인트 — docs-lib 에서 버전 확인 필요). `agent: "codeassist"`, 모델은 opencode.json 기본.
4. 큐에서 `message.part.delta`(text) → `event: token {delta}`, `session.updated` 의 title 바뀜 → `event: title`, `session.idle` → usage 계산 → `event: usage``event: done`. 에러 이벤트 → `event: error`.
5. 이미지: OpenCode 메시지 parts 의 file 파트(mime + data URL)로 전달. **구현 전 docs-lib 로 파트 스키마 확인** (추측 금지).
6. 취소 `POST /chat/sessions/{id}/cancel` → OpenCode `abort` + `is_generating=false`.
7. 프론트가 스트림을 잃어도 `isGenerating` 폴링이 미러 DB 를 보니 답변 복구됨 — 기존 동작 그대로.
### 5.5 OpenCode workspace (`web/opencode-ca/` 제안)
- `AGENTS.md`: 한국어, ABAP/SAP 코드 즉답, 코드펜스 언어 태그, 불필요한 절차 질문 금지, `explain` 플래그면 배경 설명.
- `opencode.json`: provider = 고객사면 `/api/ito` 게이트웨이(OpenAI 호환), 개발이면 OpenRouter. `tools.question=false`(웹과 같은 이유 — 답할 UI 없음). MCP `sap-icf` 연결.
- `.opencode/agent/codeassist.md`: 시스템 프롬프트. `forcedSkill` 은 1차 무시.
### 5.6 배포
- compose: `opencode-ca` 서비스 추가(이미지 `Dockerfile.opencode` 재사용, 볼륨 `../opencode-ca:/workspace`, `oc_ca_data`), `be``OPENCODE_CA_BASE_URL=http://opencode-ca:4096`. nginx 에 `/ca-api/` location. bare `run.sh` 에 프로세스 하나 추가.
- CodeAssist 빌드: `2_frontend/.env.production``VITE_API_BASE_URL=https://<서버>/ca-api/v1`. CORS 는 Tauri origin(`tauri://localhost`, `http://tauri.localhost`) 허용 — 서버 `CORS_ORIGINS` 에 추가.
## 6. 남는 결정 (사용자가 정할 것)
1. **Django 코드 위치** — ABAP_OPENCODE `web/BE` 에 앱 추가(추천, 인프라 공유) vs CodeAssist 용 Django 별도 repo. 별도면 accounts·ITO·배포를 복제해야 함.
2. **인증 저장** — 1차 localStorage(빠름) vs 처음부터 Tauri 저장소(안전). 토큰이 PC 에 남는 건 둘 다 같고, 보안 차이는 다른 프로세스가 읽기 쉬운가 정도.
3. **Entra 로그인** — 1차 501 로 끄기(추천) vs 바로 구현. 고객사 폐쇄망이면 어차피 못 씀.
4. **OpenCode 분리** — 컨테이너 분리(추천) vs 하나에 agent 만 나눔. 후자는 AGENTS.md 충돌을 프롬프트로 덮어야 해서 불안정.
5. **스니펫 서버 동기화** — 지금은 로컬 SQLite. 팀 공유 원하면 2차 (`ca_snippet` 테이블 + 동기화).
## 7. 단계 (spec 파면 이 순서로 tasks 뽑을 것)
| 단계 | 내용 | 검증 |
|---|---|---|
| 1 | `apps/codeassist` 뼈대 + envelope renderer + `/ca-api/v1` 배선 + auth 4개(login/refresh/logout/me) + entra 501 | 프론트 로그인 화면이 실제로 통과 |
| 2 | 미러 테이블 + sessions list/create/messages/search | 목록·검색 화면 |
| 3 | opencode-ca 컨테이너 + workspace + Django `OPENCODE_CA_BASE_URL` | curl 로 세션 생성·프롬프트 |
| 4 | 스트림 어댑터(token/title/usage/done/error) + cancel + isGenerating | Snap 채팅 스트리밍, 중단, 재진입 복구 |
| 5 | 이미지 파트 + `explain` | 캡처→질문 |
| 6 | 프론트 토큰 저장 2줄 + `.env.production` + CORS + 배포 스크립트 | Tauri 빌드에서 end-to-end |
각 단계 전 `docs-lib/` 에 OpenCode 서버 API 문서(세션·메시지 parts·event 스키마) 떨어뜨리고 보면서 짬. 지금 두 repo 다 그 문서 없음 — 1단계 준비물.
@@ -0,0 +1,48 @@
# snap 채팅 재진입 시 대화 안 보임 (됐다 안 됐다)
## 증상
- 답변 스트리밍 중 목록 갔다 오면 대화가 비어 보이고, 새로고침해야 나타남
- 재진입 시 답변을 처음부터 타자기로 다시 뿌림
- "되묻기(의도 애매)" 답변에서 유독 자주 아무것도 안 보임
- 재현이 타이밍 따라 들쭉날쭉 (됐다 안 됐다)
## 원인 (겹친 층 4개)
1. **전역 `staleTime: 30_000` (진짜 뿌리)** — 세션 detail 캐시가 30초간 refetch 안 됨.
빈/부분 스냅샷이 캐시에 박히면 재진입해도 그대로 나옴. 테스트 들락날락이 다 30초 창
안이라 "새로고침해야 나타남"으로 체감. 아래 3개 수정이 다 "새 데이터 오면" 작동하는
구조였는데 새 데이터 자체가 안 오고 있었음.
2. **remount 시 무조건 재seed** — per-instance `initedRef` 가드는 remount 마다 리셋 →
stale DB 스냅샷이 라이브 store 를 덮고 스트림도 abort(seed 가 abort 함).
3. **타자기 진행도가 컴포넌트 로컬**`useSmoothedText` 의 displayed 가 `""` 시작이라
remount 마다 처음부터 재생.
4. **재진입 시 "생성 중" 표시 없음** — 생성 중 재진입하면 DB 엔 user 만 있어서 시머 없이
백지. 되묻기 경로는 응답이 느려서(관측치 43초) 이 백지 창이 유독 길었음 → "되묻는
부분에서 안 보임"으로 체감.
## 확정 근거
- dev DB 직접 조회(asyncpg): 되묻기 포함 모든 메시지 정상 저장돼 있었음 → 백엔드 무죄.
세션 34b5b639: user 06:59:57 → 되묻기 assistant 07:00:40 (len=302, **43초**) → 후속
user 07:00:49 → 본답변 07:01:00 (len=1347).
- 백엔드 streaming.py: persist → usage → done 순서. 클라 끊겨도 producer 가 끝까지 저장
→ DB 가 신뢰 가능한 복구원.
## 수정 (전부 2_frontend/src/features/snap + lib/streaming)
| 파일 | 내용 |
|---|---|
| `api/snap.api.ts` | detail 쿼리 `staleTime: 0` (재진입마다 refetch) + `isGenerating` 이면 1.5s 폴링 |
| `pages/SessionChatPage.tsx` | seed 가드: 라이브면 보존, 아니면 "내용량 많은 쪽으로 단조 수렴"(DB > store 일 때만 seed). 재진입+백엔드 생성 중이면 시머 표시 + Composer 잠금(409 방지) |
| `lib/streaming/useSmoothedText.ts` | displayed 초기값 `""``full` (remount 시 기존 내용 즉시, 새 토큰만 타자기) |
| `hooks/useSnapChat.ts` | `onDone` 에서 detail 캐시 invalidate |
## 교훈
- "됐다 안 됐다" = 레이스. 코드 추리로 5번 고치다 실패 → DB 직접 조회 한 방에 백엔드
무죄 확정 + 프론트로 범위 좁힘. **증거 수집을 먼저 했어야 함.**
- 전역 캐시 정책(staleTime)은 실시간성 데이터(스트리밍 대화)에 그대로 두면 안 됨 —
쿼리별 override 필수.
- 하위 층을 아무리 고쳐도 최상층(캐시)이 데이터를 안 내려주면 전부 무력화됨. 층이 겹친
버그는 층별로 분리해서 검증할 것.
## 남은 한계 (알고 수용)
- STOP 으로 얼린 부분 답변은 재진입 시 DB 완성본으로 교체됨(백엔드 cancel 미구현이라
어차피 끝까지 생성·저장). "정지 자리 유지"가 필요하면 백엔드 cancel 이 선행.
- 멀티탭 동시 스트림은 백엔드 단일 프로세스 전제(레지스트리)라 이번 범위 밖.
@@ -0,0 +1,19 @@
# snap 답변의 ASCII 다이어그램이 말풍선 밖으로 튀어나감
## 증상
- 챗봇 답변에 든 ASCII 다이어그램(`┌───┐` 박스 라인)이 회색 인라인 코드 배경으로 줄줄이 렌더되면서 말풍선 오른쪽 밖으로 넘침
## 원인
- `Message.tsx``pre` 를 fragment 로 언랩(`pre: ({children}) => <>{children}</>`)하고, 코드펜스는 `code` 컴포넌트의 `language-*` className 매칭으로만 CodeBlock 처리하고 있었음
- **언어 라벨 없는 코드펜스**(LLM 이 ``` 만 쓰고 다이어그램 넣는 경우)는 className 이 없어서 인라인 `code` 분기로 떨어짐 → `<pre>` 없는 인라인 코드가 됨
- 인라인은 공백에서만 줄바꿈되는데 `─────` 연속 문자는 줄바꿈 지점이 없어서 그대로 overflow
- react-markdown v10 은 v8 의 `inline` prop 이 없어짐 — className 만으로 블록/인라인 구분하면 이 케이스가 샘
## 수정
- `Message.tsx`: 블록 코드 판별을 `pre` 컴포넌트로 이동 — 펜스는 언어 유무와 무관하게 전부 `CodeBlock`(overflow-auto 스크롤 컨테이너)으로. `code` 는 순수 인라인만 담당
- 재현 테스트: `Message.test.tsx` (언어 없는 펜스 → CodeBlock, 언어 있는 펜스 유지, 인라인 유지)
- `StreamingText.tsx``pre` 를 살려두고 `overflow-x-auto` 도 있어서 무관 — 안 건드림
## 교훈
- react-markdown 에서 "블록 코드 = pre 의 자식" 이 유일하게 안전한 구분 기준. `language-*` className 은 언어 없는 펜스에서 빠짐
- `pre` 를 언랩할 거면 언어 없는 펜스가 어디로 떨어지는지 반드시 확인
@@ -0,0 +1,42 @@
# 2026-08-05 스니펫 팔레트가 비어있음 (시드 실패)
## 증상
데스크톱 앱 실행 후 `Ctrl+1` 팔레트에 스니펫이 **하나도 안 뜸**. "기존 SQLite를 복사해서 시드하기로" 했는데 빈 목록.
## 조사 (systematic-debugging)
- 앱 로그(`%TEMP%\codeassist-webview.log`): `SnippetSeed: 시드 소스 없음(빈 DB로 시작): D:\project\003.short-auto\snippets.db`
- `find`로 003 트리 전체 스캔 → `.db`/`.sqlite` **하나도 없음**. 003(파이썬)이 실제로 한 번도 안 돌아가서 `snippets.db`가 생긴 적이 없었음.
- 타겟 `%LOCALAPPDATA%\CodeAssist\snippets.db`는 이미 존재(앱이 첫 실행 때 빈 스키마 생성). `SnippetSeed`는 "타겟 있으면 스킵"이라 **재시드도 막힘**.
## 근본 원인
1. **시드 소스(SQLite)가 애초에 존재하지 않았음.** 진짜 스니펫 데이터는 C# 원본의 **XML** `0_reference/CodeTemplates.xml`(231개 `<Code>`)에 있음. 시드는 없는 SQLite를 복사하려 했으니 빈 DB.
2. 빈 타겟 db가 이미 생성돼 있어 소스를 고쳐도 재시드 안 됨.
## 해결
1. `CodeTemplates.xml` → 앱 스키마(SnippetRepository) SQLite로 **1회 변환**(아래 스크립트). 231개 중 218개 저장(빈 body 10·중복 이름 3 스킵). name은 `NormalizeName`(Trim·대문자·공백→`_`)과 동일 규칙, category 기본 `"코드"`.
2. 생성물을 시드 소스 경로 `D:\project\003.short-auto\snippets.db`에 두고(향후 재시드용), 앱 종료 후 빈 타겟(`%LOCALAPPDATA%\CodeAssist\snippets.db` + wal/shm) 제거 → 218개 db로 교체.
3. 앱 재실행 → 218개 표시.
## 재발 방지 / 메모
- 코드 변경 없음(시드 로직 자체는 정상 — 소스 파일이 없었던 게 문제). 소스 경로에 db가 생겼으니 타겟 삭제 시 시드가 정상 복구.
- 데이터 원천은 **SQLite가 아니라 XML**이었음. XML이 갱신되면 아래 스크립트로 재생성하면 됨.
- `change_word1/2`(XML의 ChangeWord1/2)는 앱 스키마에 없음 — 의도적으로 안 가져옴.
## 변환 스크립트 (보존)
```python
import xml.etree.ElementTree as ET, sqlite3, os
SRC_XML = r"D:\project\003.short-auto\0_reference\CodeTemplates.xml"
OUT_DB = r"D:\project\003.short-auto\snippets.db"
def norm(n): return n.strip().upper().replace(" ", "_") # SnippetRepository.NormalizeName 동일
root = ET.parse(SRC_XML).getroot()
rows, seen = [], set()
for code in root.findall("Code"):
name = norm(code.findtext("Name") or ""); desc = (code.findtext("Desc") or "").strip(); body = code.findtext("Snippet") or ""
if not name or not body.strip() or name in seen: continue
seen.add(name); rows.append((name, desc, body, "코드"))
if os.path.exists(OUT_DB): os.remove(OUT_DB)
con = sqlite3.connect(OUT_DB)
con.executescript("CREATE TABLE snippets(name TEXT PRIMARY KEY,desc TEXT NOT NULL DEFAULT '',body TEXT NOT NULL DEFAULT '',category TEXT NOT NULL DEFAULT '코드');CREATE TABLE usage(name TEXT PRIMARY KEY,count INTEGER NOT NULL DEFAULT 0,last_used INTEGER NOT NULL DEFAULT 0);")
con.executemany("INSERT INTO snippets(name,desc,body,category) VALUES(?,?,?,?)", rows); con.commit(); con.close()
# 그다음: 앱 종료 → %LOCALAPPDATA%\CodeAssist\snippets.db(+wal/shm) 삭제 → OUT_DB 복사 → 앱 재실행
```
@@ -0,0 +1,37 @@
# Tauri 단축키 마이그레이션 점검
## 원인
- 닷넷 `App.xaml.cs`는 Ctrl+Shift+7/8/9, Tauri `lib.rs`는 동시 비교용 Ctrl+Alt+7/8/9를 등록하고 있었음.
- 프론트 `bridgeNavigate.ts``chrome.webview`만 구독해서 Rust `bridge` 이벤트를 받지 못함.
- `webviewBridge.ts`도 닷넷에만 전송해서 현재 route 보고와 Esc 창 숨김이 Tauri에서 무반응이었음.
## 수정과 대조 결과
| 키 | 닷넷 동작 | Tauri 점검 결과 |
|---|---|---|
| Ctrl+Shift+7 | 스니펫 소환, 검색창 포커스 | 조합 수정, 화면 이동·동일 route 재소환 이벤트 연결. 저장소는 여전히 미구현 |
| Ctrl+Shift+8 | 챗봇 표시/숨김, 마지막 비스니펫 route 복귀 | 조합 수정, 현재 route 보고·이동 수신 연결 |
| Ctrl+Shift+9 | 영역 선택 → 새 대화에 이미지 첨부 | 조합만 수정. Rust 캡처 핸들러는 여전히 로그만 남기는 스텁 |
| Esc | 목록 검색 초기화 후 창 숨김, 대화 화면에서는 목록 복귀 | 같은 React 코드 사용. 누락된 Tauri 창 숨김 전송 연결 |
| Ctrl+N | 새 대화 | 같은 SnapLayout 코드 사용 |
| ↑/↓, Enter | 대화 목록 선택/열기 | 같은 SessionListPage 코드 사용 |
| Enter / Shift+Enter | 메시지 전송 / 줄바꿈 | 같은 Composer 코드 사용 |
| 스니펫 ↑/↓, F2, Ctrl+N, Ctrl+←/→ | 선택, 편집, 생성, 분류 이동 | 같은 React 핸들러. 데이터 요청 통로·Rust 저장소 미구현으로 전체 사용 흐름은 미완료 |
| 스니펫 Enter | 직전 앱에 붙여넣기 | Rust paste_code가 스텁. 단축키 변경만으로 해결되지 않음 |
스니펫 핸들러 주석의 Alt+숫자는 실제 분기에 없음. 두 호스트가 공유하는 기존 프론트의 상태이므로 마이그레이션 누락과 구분함.
## 검증
- 공식 Tauri mockIPC 및 이벤트 mock으로 현재 route 보고, 숨김, 화면 이동, 같은 route 재소환, 중복 구독 방지, 닷넷 우선, 브라우저 no-op 확인.
- 프론트 전체 테스트 43파일·242개 통과.
- 프론트 프로덕션 빌드 통과. 전체 lint는 기존 FileDropZone·skill-mapping 오류 10개와 경고 10개로 실패했고, 이번 변경 파일의 오류는 없음.
- `cargo test --locked` 통과. Rust 테스트 0개이므로 실제 키 입력 검증을 뜻하지 않음.
- 네이티브 실제 키 입력 검증은 미실시. 기존 요청에 따라 Computer Use는 사용하지 않음.
## 남은 사항
- 닷넷과 Tauri를 함께 실행하면 같은 키를 선점하므로 한 번에 한 호스트로 확인해야 함. 기존 비교용 Ctrl+Alt 조합 정책은 이번 사용자 요청으로 대체됨.
- 핫키 등록 실패 안내는 닷넷 트레이 알림과 달리 Tauri 콘솔 로그만 있음.
- 저장소·직전 앱 붙여넣기·캡처 구현은 `specs/004-tauri-shell/tasks.md`의 미완료 항목으로 유지.
@@ -0,0 +1,7 @@
# 스니펫 Enter 동작을 클립보드 복사로 변경
사용자 기대는 선택한 스니펫 본문 복사였으나 기존 Enter는 미구현 Tauri `paste_code`를 호출한 뒤 창을 숨겼음.
기존 코드 뷰어와 같은 `navigator.clipboard.writeText` 사용. 원문 공백·개행을 유지하고 복사 완료 후에만 사용기록 요청과 창 숨김 실행. 실패하면 오류 안내와 함께 창 유지. IME 조합·키 반복·진행 중 중복 복사 방지.
페이지 테스트로 항목 선택 후 Enter, 원문 보존, 비동기 성공 전 창 유지, 실패 시 사용기록·창 숨김 미실행을 검증함. Tauri 사용기록 저장은 기존 스텁 상태이며 복사 기능과 별개임.
@@ -0,0 +1,19 @@
# Tauri 스니펫 목록이 비어 보임
## 원인과 수정
기존 닷넷 DB는 `%LOCALAPPDATA%/CodeAssist/snippets.db`에 219개가 정상 보관돼 있었음. Tauri 목록 명령이 스텁이고 프론트 스니펫 요청도 닷넷만 지원해서 표시되지 않았음.
- Rust 목록 명령이 기존 DB를 읽기 전용으로 열고 snippets/usage를 조인해 기존 프론트 계약으로 반환하도록 구현.
- Tauri 스니펫 요청을 invoke로 전달하고 오류 문자열을 Error로 변환.
- 조회 오류가 나면 빈 목록 안내 대신 오류 원인을 표시.
- 원본 복사나 덮어쓰기 없이 같은 파일을 읽음. 스니펫 쓰기와 직전 앱 붙여넣기는 아직 미구현.
## 검증
- Python SQLite quick_check 정상, 219개 확인.
- Rust 스키마 테스트 통과: 한글 본문·설명과 사용기록 없는 행의 기본값 검증.
- `cargo test --locked reads_existing_dotnet_database -- --ignored --nocapture`: 실제 기존 DB 219개 조회 성공.
- 프론트 전체 실행에서 기존 244개 통과, 신규 테스트의 invoke 기본 인자 기대값 1개 수정 후 해당 파일 6개 전부 통과.
- 프론트 프로덕션 빌드 및 변경 파일 ESLint 통과. 전체 lint의 기존 오류는 직전 작업에서 확인된 상태.
- 개발 앱 자동 재빌드·재실행과 응답 정상 확인. 실제 화면 목록의 시각 확인은 사용자 확인이 남음.
@@ -0,0 +1,18 @@
# Tauri 앱이 떠 있어도 스니펫 단축키 화면 전환이 안 됨
## 원인
Windows Tauri도 WebView2를 사용해서 `window.chrome.webview`가 존재함. 기존 호스트 판별은 이 객체만 보고 닷넷 통로를 선택했음. Rust의 `bridge` 이벤트를 구독하지 않아 Ctrl+Shift+7로 창은 표시해도 `/snippet`으로 이동하지 못함. route 보고와 Esc도 같은 오판의 영향을 받음.
설치된 `wry-0.55.1/src/webview2/mod.rs:885`에서 Tauri 기반 웹뷰가 `window.chrome.webview.postMessage`를 사용하는 것을 확인함. 이전 테스트는 두 객체가 공존할 때 닷넷을 우선하는 잘못된 기대를 갖고 있었음.
## 수정
`webview()`가 Tauri의 invoke 존재 여부를 먼저 확인하고, Tauri이면 닷넷 전용 통로를 반환하지 않도록 수정. 기존 공통 접근자를 통해 이벤트 구독과 창 제어 모두 Tauri 통로로 연결함.
## 검증
- Windows처럼 두 객체가 공존하는 회귀 테스트에서 수정 전 화면 이동·창 숨김 2개 실패를 재현함.
- 수정 후 전체 43개 테스트 파일, 243개 테스트 통과.
- 실행 중 Vite의 변경 반영과 페이지 재로드 로그 확인.
- 실제 단축키 입력에 따른 화면 전환은 사용자 확인이 남음. Tauri 스니펫 저장소 구현은 별도 미완료 항목임.
@@ -0,0 +1,29 @@
# 저장된 로그인 정보 때문에 sessions·refresh 401 반복
## 증상
- 앱을 열면 로그인 화면 대신 `/api/v1/chat/sessions`를 먼저 호출하고 401이 남음.
- 이어서 `/api/v1/auth/refresh`가 호출되지만 refresh 쿠키가 없어 `REFRESH_TOKEN_MISSING` 401이 남음.
## 원인
`authStore`는 user만 localStorage에 저장하고 실제 토큰은 httpOnly 쿠키로 관리함. 그런데 `ProtectedRoute`가 저장된 user만 보고 보호 화면을 열었음. 쿠키가 이미 사라져도 user가 남아 있으면 `SessionListPage`가 먼저 마운트되어 sessions API를 호출함.
서버 진실값을 확인하려 만든 `useMe()`는 어디에도 마운트되지 않아 실제 로그인 검증에 쓰이지 않았음. `/users/me`의 refresh까지 실패하면 공통 interceptor가 요청을 세션 만료 모달 큐에 넣어 Promise를 끝내지 않으므로, 앱 시작 검증에도 그대로 쓸 수 없었음.
## 수정
| 파일 | 내용 |
| --- | --- |
| `2_frontend/src/shared/components/ProtectedRoute.tsx` | 저장된 user가 있으면 `useMe()` 검증이 끝날 때까지 보호 화면을 열지 않음. 검증 실패 시 user를 지우고 로그인으로 이동 |
| `2_frontend/src/features/auth/api/auth.api.ts` | 앱 시작 `/users/me` 요청에 세션 만료 큐 제외 표시 추가 |
| `2_frontend/src/lib/api/client.ts` | refresh 실패 시 앱 시작 검증 요청은 모달 큐에 넣지 않고 401을 호출자에게 반환 |
| `2_frontend/src/shared/components/ProtectedRoute.test.tsx` | 검증 중 보호 화면 차단과 실패 시 로그인 이동 회귀 검사 |
| `2_frontend/src/lib/api/client.test.ts` | 앱 시작 refresh 실패가 세션 만료 큐에 갇히지 않는 회귀 검사 |
## 검증
- 회귀 검사 RED: 보호 화면이 즉시 열렸고, 초기 `/users/me` 요청은 세션 만료 큐에서 5초 timeout 됨.
- 수정 후 전체 frontend 검사: 47 files, 268 tests 통과.
- `npm run build` 통과.
- 브라우저에서 stale user + 쿠키 없음으로 `/snap` 진입: `/users/me``/auth/refresh``/login?from=%2Fsnap` 이동, localStorage user 제거 확인. `/chat/sessions` 요청은 발생하지 않음.
@@ -0,0 +1,21 @@
# 로그인 입력 영역이 창 위쪽에 치우침
## 증상
로그인 창을 열면 이메일·비번 입력 영역이 가로로만 가운데 정렬되고 세로로는 위쪽에 붙어 보였음.
## 원인
`LoginPage``mx-auto``py-12`만 사용해서 세로 가운데 정렬 기준이 없었음. 공통 `Layout``main`도 flex 자식이 남은 높이를 채울 수 없는 구조였음.
## 수정
- 공통 `main`을 세로 flex 컨테이너로 바꿈
- 로그인 페이지 바깥 영역이 남은 높이를 채우고 `items-center justify-center`로 입력 영역을 가운데 배치함
- 가운데 정렬 class를 확인하는 회귀 테스트를 추가함
## 확인
- `npm run lint` 통과 (기존 warning 9개)
- 전체 테스트 269개 통과
- production build 통과
@@ -0,0 +1,19 @@
# 접힌 사이드바에서 로그아웃을 찾을 수 없음
## 증상
사이드바가 접히면 사용자 이름이 보이지 않아 사용자 메뉴 안의 로그아웃도 찾기 어려웠음.
## 원인
로그아웃이 사용자 이름을 눌러야 열리는 메뉴 안에만 있었음.
## 수정
사이드바 하단에 로그아웃을 독립 버튼으로 배치함. 펼친 상태에는 글자와 아이콘, 접힌 상태에는 아이콘과 툴팁이 표시됨.
## 확인
- 사용자 이름이 없어도 로그아웃 버튼이 동작하는 회귀 테스트 추가
- 전체 테스트 272개 통과
- lint 및 production build 통과
@@ -0,0 +1,24 @@
# 캡처 영역 확정 뒤 챗봇 창이 안 뜸
## 증상
- `Ctrl+Shift+9`를 누르면 캡처 영역 선택 화면까지는 정상적으로 떴음.
- 영역을 선택해도 챗봇 창과 새 대화 화면이 열리지 않았음.
- 캡처 이미지는 클립보드에도 들어가지 않았음. 클립보드는 원래 바꾸지 않는 설계라 이 부분은 정상임.
## 원인
- 영역 확정 처리에서 `DestroyWindow`를 먼저 호출했음.
- `DestroyWindow``WM_NCDESTROY`를 동기로 처리하며 `OverlayState`를 해제했음.
- 그 뒤 해제된 상태의 `sender`로 선택 영역을 보내려 해서 캡처 완료 콜백까지 결과가 전달되지 않았음.
## 수정
- 선택 영역을 채널로 먼저 보낸 뒤 마우스 캡처를 풀고 오버레이 창을 닫게 순서를 바꿈.
- 결과 전달 뒤에는 `OverlayState`를 다시 읽지 않게 함.
## 확인
- 캡처 좌표 단위 테스트를 통과함.
- Rust 전체 테스트와 빌드로 기존 기능 회귀가 없는지 확인함.
- Win32 오버레이의 실제 마우스 조작은 데스크톱 앱에서 수동 확인이 필요함.
@@ -0,0 +1,21 @@
# Ctrl+Shift+8이 챗봇 대신 다른 화면을 염
## 증상
- `Ctrl+Shift+8`로 CodeAssist를 열면 챗봇이 아닌 로그인·파일 드롭 같은 화면이 다시 뜨었음.
- 챗봇이 아닌 화면에서 단축키를 누르면 창이 숨어 토글 동작도 틀렸음.
## 원인
- route 보고 상태가 `/snippet`만 아니면 모든 경로를 `last_snap`으로 저장했음.
- 창 숨김 조건도 `/snippet`이 아닌 모든 화면을 챗봇으로 보고 있었음.
## 수정
- `last_snap``/snap` 또는 `/snap/...` 경로에서만 바꾸게 함.
- 현재 화면이 `/snap` 계열이고 창이 보일 때만 숨기고, 나머지는 마지막 챗봇 화면으로 이동하게 함.
## 확인
- 파일 드롭 화면이 `last_snap`을 덮어쓰는 재현 테스트를 먼저 추가해 실패를 확인함.
- 파일 드롭 화면에서 단축키를 누르면 숨지 않고 마지막 챗봇으로 가는 회귀 테스트를 추가함.