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
@@ -0,0 +1,35 @@
# Specification Quality Checklist: 스니펫 팔레트 (Snippet Palette)
**Purpose**: 계획(plan) 넘어가기 전 spec 완성도·품질 확인
**Created**: 2026-08-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [~] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [~] No implementation details leak into specification
## Notes
- **"No implementation details" (~)**: 이 기능은 "기존 아키텍처(React + WebView2 + 기존 브릿지)에 003을 재개발 이식"이 사용자가 못박은 전제라, 아키텍처 참조(SQLite·브릿지·C# 호스트·Raycast 레이아웃)가 의도적으로 들어감. 다만 그런 구현 앵커는 대부분 **Assumptions 섹션**에 몰아뒀고, 기능 요구(FR)·성공기준(SC)은 사용자 관점의 동작·결과로 기술함. spec-kit 순수주의보단 이 프로젝트 현실(이식·재사용 우선)을 택함.
- 나머지 항목 전부 통과. `/speckit-clarify` 건너뛰고 `/speckit-plan`으로 가도 됨(모호점 없음). plan 단계에서 `2_frontend/docs-lib/` 라이브러리 문서 먼저 참조 규칙 적용할 것.
@@ -0,0 +1,85 @@
# Contract: 스니펫 브릿지 메시지
기준 앱은 Tauri이고 환경별 전달은 `specs/004-tauri-shell/contracts/transport-mapping.md`를 따름. 아래 C# 요청·응답 설명은 참고용 legacy 채널 계약임.
## Tauri 단계별 창 크기 (2026-09-11)
`{type:"window.snippetLayout", stage:"search"|"results"|"preview"|"editor"}``invoke("window_snippet_layout", {stage})`.
- 검색 `640×84`, 결과 `640×440`, 프리뷰 `840×520`, 편집 `960×600` 논리 픽셀. 실제 크기와 DPI·작업 영역 보정은 Rust 소유.
- 호스트가 Tauri일 때만 전송. legacy·브라우저는 화면 단계만 바뀌고 실제 창 크기는 제어하지 않음.
- 다른 route 보고 시 원래 배치를 복원. 종료 직전에 복원한 크기를 저장해 축소 상태가 일반 창 크기를 덮어쓰지 않음.
- 허용되지 않은 stage와 크기 조작 실패는 reject. 기존 transport 오류 토스트로 표시.
## Tauri 앱에 붙여넣기 (2026-09-11)
`{type:"paste.code", text}``invoke("paste_code", {text})`. 스니펫 `Ctrl+Enter`와 기존 코드뷰어 붙여넣기 버튼이 같은 경로를 사용함.
- `Enter`는 기존 원문 복사 후 창 숨김, 복사 버튼은 복사만 하고 창 유지. `Ctrl+Enter`는 선택한 코드에만 적용하고 데스크톱·미편집·비조합 상태에서 한 번만 실행.
- 호스트는 키 해제를 기다린 뒤 직전 앱으로 돌아가고, 실제 입력 직전에 대상·포커스·키 상태를 다시 확인. 대기 중 UI는 멈추지 않고 중복 붙여넣기는 실행하지 않음.
- 성공한 입력 전송 뒤에만 창을 숨김. 실패하면 창을 유지하거나 다시 표시하고 기존 bridge 오류 토스트로 안내.
- 클립보드 기록 뒤에 실패하면 원문은 그대로 남김. 클립보드 자체 기록 실패에는 복사됐다고 안내하지 않음.
- 다른 앱의 편집기가 실제로 내용을 받아들였는지까지 성공 응답이 보장하지는 않음. 별도 입력창에서 end-to-end로 검사하고, 관리자 권한 앱 등 Windows 입력 제한은 오류로 구분.
## 기존 (변경 없음)
| 방향 | 메시지 | 처리 |
|---|---|---|
| JS→C# | `{type:"window.hide"}` | 런처 창 숨김 |
| JS→C# | `{type:"paste.code", text}` | 직전 창에 `text` 붙여넣기 + 창 숨김 |
현재 Enter는 선택한 스니펫 원문을 클립보드에 복사한 뒤 창을 숨김. 기존 `paste.code`는 코드뷰어의 붙여넣기 버튼과 스니펫 Ctrl+Enter에서 사용함. Tauri의 오류·창 표시 동작은 위 절을 따름.
## 신규 — C#→JS 푸시(내비게이션)
| 방향 | 메시지 | 처리 |
|---|---|---|
| C#→JS | `{type:"navigate", path}` | 프론트가 react-router로 `path`로 이동 |
- 스니펫 핫키(Ctrl+1) → C# `ShowPalette()``SendToWeb({type:"navigate", path:"/snippet"})`.
- 프론트 `bridgeNavigate.ts`가 수신 → 등록된 navigate 콜백 호출.
## 신규 — 스니펫 데이터(요청 → 응답, reqId 상관)
각 요청은 프론트가 부여한 `reqId`(문자열, 단조 증가) 포함. C#는 같은 `reqId`로 결과를 돌려준다.
### 요청 (JS→C#)
| 메시지 | 의미 |
|---|---|
| `{type:"snippets.list", reqId}` | 전체 스니펫(usage 병합) 반환 |
| `{type:"snippets.create", reqId, snippet:{name,desc,body,category}}` | 생성(중복 name 거부) |
| `{type:"snippets.update", reqId, snippet:{name,desc,body,category}}` | 수정(name=키, 나머지 갱신) |
| `{type:"snippets.delete", reqId, name}` | 삭제 |
| `{type:"snippets.recordUse", reqId, name}` | usage count+1, last_used=now |
### 응답 (C#→JS)
```jsonc
// 성공
{ "type":"snippets.result", "reqId":"<echo>", "ok":true, "data": <op > }
// 실패
{ "type":"snippets.result", "reqId":"<echo>", "ok":false, "error":"<사람이 읽을 메시지>" }
```
| 요청 | 성공 `data` |
|---|---|
| `snippets.list` | `Snippet[]` (usageCount·lastUsed 병합) |
| `snippets.create` / `update` | 저장된 `Snippet` (또는 갱신 후 전체 목록 — 구현 단순한 쪽) |
| `snippets.delete` | `{name}` |
| `snippets.recordUse` | `{name, usageCount, lastUsed}` |
**에러 계약**: 중복 name 생성/빈 필드 → `ok:false` + 한글 메시지. 프론트는 `ok:false``Error`로 reject → react-query `onError` → sonner 토스트(memos 패턴).
## C# 라우팅
`OnWebMessageReceived``type`으로 분기:
- `window.hide` / `paste.code` → 기존.
- `snippets.*``SnippetBridge`(신규)에 위임: JSON에서 `reqId`·`snippet`·`name` 파싱 → `SnippetRepository` 호출 → `SendToWeb``snippets.result` 응답.
- 파싱은 기존 `type`+`text`만 읽던 걸 확장(요청별 필요한 필드 추출).
## 프론트 리스너
- `snippetBridge.ts`: `Map<reqId,{resolve,reject}>`, `chrome.webview.addEventListener("message")`에서 `snippets.result`만 필터해 매칭 reqId resolve/reject. 각 op은 `postMessage`+promise.
- `bridgeNavigate.ts`: 같은 message 이벤트에서 `navigate`만 필터. (리스너 여러 개 가능 — 서로 독립.)
- 순수 브라우저(`isWebView()===false`): 요청은 즉시 빈 결과/비활성(데스크톱 전용, research R7).
@@ -0,0 +1,42 @@
# Contract: 프론트 데이터 인터페이스 (`snippets.api.ts`)
feature의 유일한 데이터 진입점. 화면·hook은 이 인터페이스만 알고, 뒤가 브릿지(SQLite)인지 나중에 백엔드인지는 모른다. (spec FR-022 / plan 편차1: HTTP 클라이언트 미경유)
```ts
import type { Snippet, SnippetInput } from "../types"
export interface SnippetsApi {
list(): Promise<Snippet[]> // usage 병합, 정렬은 프론트가
create(input: SnippetInput): Promise<Snippet> // 중복 name → reject(Error)
update(input: SnippetInput): Promise<Snippet> // name=키
remove(name: string): Promise<void>
recordUse(name: string): Promise<{ name: string; usageCount: number; lastUsed: number }> // 붙여넣기 성공 후, 갱신된 usage 반환(caller는 무시 가능)
}
```
- v1 구현체 `snippetBridge`(브릿지 왕복). 나중에 백엔드 전환 시 이 인터페이스 뒤 구현만 교체.
- 반환 타입은 순수 데이터(브릿지/HTTP 세부 노출 안 함). 실패는 `Error` reject.
## react-query 계약 (`hooks/useSnippets.ts`) — memos 패턴 준용
```ts
export const SNIPPETS_KEY = "snippets"
useSnippets() // useQuery([SNIPPETS_KEY]) → Snippet[]
useCreateSnippet() // mutation → invalidate([SNIPPETS_KEY]) + sonner "스니펫 생성됨"
useUpdateSnippet() // → invalidate + "저장됨"
useDeleteSnippet() // → invalidate + "삭제됨"
useRecordUse() // → invalidate([SNIPPETS_KEY]) (랭킹 갱신). 토스트 없음(조용히)
```
- 에러: 브릿지 실패는 `ApiError`(HTTP 전용)가 아니라 rejected promise의 순수 `Error`임 → `err instanceof Error ? err.message : "<한글 폴백>"` 형태(memos 톤은 같되 타입만 Error).
- `useSnippets`가 준 목록에 `core/search`+`core/ranking`을 화면에서 적용(서버 정렬 아님 — 로컬 계산).
## 붙여넣기 (기존 재사용, 신규 아님)
```ts
import { pasteToApp } from "@/lib/bridge/webviewBridge"
// Enter / Alt+숫자 →
pasteToApp(snippet.body) // 기존 paste.code — C# PasteService가 앞 창에 Ctrl+V + 창 숨김
await recordUse(snippet.name)
```
+91
View File
@@ -0,0 +1,91 @@
# Data Model: 스니펫 팔레트 (Phase 1)
003 스키마를 그대로 따른다. 랭킹·검색은 프론트에서 계산하므로, 저장은 원자적 데이터만 담는다.
## 엔티티
### Snippet
붙여넣을 코드 조각 한 개.
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `name` | string | **PK**, 정규화(대문자·공백→`_`), 유일 | 식별 키, 결과 행 제목 |
| `desc` | string | 기본 `""` | 짧은 설명, 결과 부제 · 검색 대상 |
| `body` | string | 비어있지 않음, 원문 보존 | 실제 붙여넣어지는 내용 |
| `category` | string | 기본 `"코드"` | 분류 라벨 |
- 003의 `change_word1/2`**가져오지 않음**(안 쓰는 유물).
- 저장 시 검증: `name` 정규화 후 비어있지 않음 + 유일(중복 거부), `body` 비어있지 않음. (spec FR-016)
- `name`은 편집 불가(키). desc/body/category만 수정. (FR-017)
### Usage
스니펫별 사용 통계. 별도 테이블(003과 동일).
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `name` | string | **PK**, Snippet.name 참조 | 어떤 스니펫 |
| `count` | integer | 기본 0 | 붙여넣기 성공 누적 |
| `last_used` | integer | unix 초 | 마지막 붙여넣기 시각 |
- 붙여넣기 성공 시 upsert: `count+1`, `last_used=now`. (FR-011)
- Snippet에 대응 Usage가 없으면 `(0,0)`으로 취급.
## SQLite 스키마 (C# 호스트 소유)
```sql
CREATE TABLE IF NOT EXISTS snippets (
name TEXT PRIMARY KEY,
desc TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '코드'
);
CREATE TABLE IF NOT EXISTS usage (
name TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
last_used INTEGER NOT NULL DEFAULT 0
);
```
파일: `%LOCALAPPDATA%\CodeAssist\snippets.db`. 첫 실행 시 003의 `snippets.db`가 있으면 1회 복사(시드). (research R4·R5)
## 프론트 타입 (`features/snippets/types.ts`)
로컬 엔티티라 `types/api.ts`(서버 DTO 전용) 아님.
```ts
export interface Snippet {
name: string
desc: string
body: string
category: string
usageCount: number // usage.count (없으면 0)
lastUsed: number // usage.last_used unix초 (없으면 0)
}
export interface SnippetInput { // 생성/수정 입력
name: string
desc: string
body: string
category: string
}
```
- 브릿지 응답에서 snippets ⨝ usage 조인 결과를 `Snippet`(usageCount/lastUsed 병합)로 받는다. C#가 조인해 내려주거나, 두 테이블을 각각 내려 프론트에서 병합 — 구현 시 단순한 쪽(C# 조인 권장).
## 파생 규칙 (프론트 순수함수)
### 검색 (`core/search.ts`) — FR-005/006
- 입력: 스니펫 목록, 쿼리 문자열, (선택)category.
- category가 있으면 그 분류로 먼저 제한. `"전체"`면 전체.
- 쿼리를 공백으로 split → 키워드들. 각 키워드(대문자화)가 **전부(AND)** `(name + " " + desc)`의 대문자화 문자열에 포함될 때만 통과. `body`는 검색 안 함. 퍼지 없음.
- 빈 쿼리 → (category 제한된) 전체.
### 랭킹 (`core/ranking.ts`) — FR-007
- 정렬 키: `count desc, lastUsed desc, name asc`.
- 모든 결과 집합(검색·전체 공통)에 적용. 상위 9개에 `1~9` 번호(Alt+숫자 대응). (FR-008)
## 상태 전이
스니펫: (없음) → 생성 → (편집)* → 삭제.
Usage: 붙여넣기 성공마다 count 증가 · last_used 갱신 → 랭킹 상단으로 부상.
+107
View File
@@ -0,0 +1,107 @@
# Implementation Plan: 스니펫 팔레트 (Snippet Palette)
**Branch**: `001-snippet-palette` | **Date**: 2026-08-05 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/001-snippet-palette/spec.md`
## 2026-09-11 실행 범위
현재 구현 계획은 `spec.md`의 단계별 런처 확정을 따름. 아래 .NET 초기 이식 계획은 미완료 저장 기능의 배경이며, 이번 작업은 Tauri 기준으로 진행함.
1. `SnippetPalettePage.tsx`: 빈 검색 결과 숨김, 명시적인 선택만 프리뷰 표시, 검색·분류·재소환 시 선택 해제. 기존 검색·랭킹·복사 함수와 편집기를 재사용.
2. `SnippetRow.tsx``PaletteShell.tsx`: 테두리 없는 행과 선택 강조, 복사 전용 런처에는 대상 앱 배지를 표시하지 않음.
3. `window.snippetLayout`을 기존 transport로 전달. Rust `commands.rs`가 단계별 크기를 선택하고 `shell/window.rs`가 이전 배치 보관·작업 영역 보정·복원을 담당.
4. `report_route`에서 다른 화면 복원. 창이 닫힐 때 복원한 실측값을 window-state 캐시에 명시적으로 저장해 종료 이벤트 순서에 의존하지 않음.
5. 검색 전환·원문 복사·IME·Esc 포커스·삭제 확인 닫힘은 Vitest, 경계 계산은 Rust 검사. 실제 Tauri에서 크기 전환·최대화 복원·축소 상태 종료 후 재시작 확인.
## Summary
003(파이썬 winkit-snippet)의 스니펫 검색·붙여넣기 기능을 이 앱(React SPA + .NET WebView2 런처)에 재개발 이식한다. 전역 핫키 `Ctrl+1`로 팔레트를 소환해, 검색해서 방금 쓰던 창에 스니펫을 붙여넣는다. UI는 Raycast 결. 저장은 C# 호스트가 소유한 로컬 SQLite(003 스키마 그대로), React는 WebView2 브릿지로 접근.
**기술 접근(재사용 우선):** 이 앱은 이미 팔레트에 필요한 걸 대부분 갖고 있다 — 커맨드 팔레트 UI(`/snap``SessionListPage`: 자동포커스 검색 + ↑↓/Enter nav + IME 안전 + 단계 Esc), 앞 창에 붙여넣는 배선(`pasteToApp``PasteService`), 전역 핫키·트레이·단일실행·앞창 캡처. 그래서 신규는 딱 세 곳만 새로 짠다: (1) 두 번째 전역 핫키, (2) C#→JS `navigate` 신호, (3) React↔SQLite 요청-응답 브릿지 + C# `SnippetRepository`. 나머지는 memos(feature 모듈 카논)·snap(팔레트)의 패턴 복제·리스킨.
## Technical Context
**Language/Version**: TypeScript 5 / React 18 (Vite) · C# / .NET (WPF, WebView2)
**Primary Dependencies**: @tanstack/react-query, shadcn/ui, sonner (프론트) · Microsoft.Web.WebView2, **Microsoft.Data.Sqlite(신규 추가)** (C#)
**Storage**: 로컬 SQLite 파일 `snippets.db`(C# 호스트 소유, 003과 동일 스키마). 브라우저 아님.
**Testing**: vitest(프론트: 검색/랭킹 순수함수 + 브릿지 mock) · xUnit(C#: `SnippetRepository` CRUD/usage against temp db) — TDD(RED→GREEN)는 superpowers 뒷단에서.
**Target Platform**: Windows 데스크톱(WebView2 런처). 순수 브라우저(dev)는 브릿지 no-op → 스니펫 기능 비활성(데스크톱 전용).
**Project Type**: 기존 웹앱(2_frontend) + 데스크톱 호스트(3_windowsApp)에 feature 추가.
**Performance Goals**: 핫키→팔레트 표시 체감 즉시(수백 ms). 검색은 수백~수천 스니펫에서 substring 필터로 즉각(퍼지 없음).
**Constraints**: 붙여넣기 대상은 항상 "소환 직전 창"(기존 캡처 로직 재사용). 붙여넣기 순서 계약(포커스→클립보드→포커스→Ctrl+V)은 기존 `PasteService`가 이미 지킴.
**Scale/Scope**: 개인 단일 PC. 스니펫 수백~수천 규모. 화면 1개(팔레트) + 편집 다이얼로그.
## Constitution Check
*GATE: Phase 0 전 통과, Phase 1 후 재확인.*
`.specify/memory/constitution.md`는 빈 템플릿 → 형식 게이트 없음. **실질 캐논은 `CLAUDE.md`**(재사용 우선·feature 모듈 컨벤션·react-query·TDD·docs-lib 선참조). 이에 비춘 점검:
-**재사용 우선**: 팔레트 UI(snap)·붙여넣기(pasteToApp)·핫키/트레이/캡처 전부 기존 것 재사용. 신규는 최소 3곳.
-**feature 모듈 컨벤션**: `features/snippets/{api,hooks,components,pages}` + react-query(KEY 상수·invalidate·sonner 한글 토스트·ApiError 분기)로 memos 패턴 준용.
- ⚠️ **의도된 편차 1 — 데이터 접근이 `@/lib/api/client`(apiGet/apiPost) 경유 아님**: CLAUDE.md 4번은 "axios/HTTP는 apiGet/apiPost만"인데, 스니펫 저장은 **HTTP 백엔드가 아니라 로컬 SQLite(WebView2 브릿지)**다. 그래서 `snippets.api.ts`는 브릿지 백엔드로 구현. react-query로 감싸는 건 유지. → 백엔드가 아니라 편차가 정당(사용자가 로컬 SQLite로 결정). tasks/impl에서 명시.
- ⚠️ **의도된 편차 2 — 타입 위치**: 서버 DTO는 `types/api.ts`가 규칙이나, `Snippet`은 서버 DTO가 아니라 로컬 엔티티 → snap이 `features/snap/contract/types.ts` 두는 것처럼 `features/snippets/types.ts`에 둠. 정당.
Gate: **PASS** (편차 2건은 문서화된 정당 편차, 복잡도 증가 아님).
## Project Structure
### Documentation (this feature)
```text
specs/001-snippet-palette/
├── plan.md # 이 파일
├── research.md # Phase 0 — 미해결점 결정(브릿지 왕복·멀티핫키·SQLite·시드·프리뷰)
├── data-model.md # Phase 1 — Snippet/Usage 엔티티 + DB 스키마 + TS 타입 + 랭킹규칙
├── contracts/ # Phase 1 — 브릿지 메시지 계약 + 프론트 데이터 인터페이스
│ ├── bridge-messages.md
│ └── snippet-data-interface.md
├── quickstart.md # Phase 1 — end-to-end 검증 시나리오
└── checklists/requirements.md
```
### Source Code (repository root)
```text
2_frontend/src/
├── features/snippets/ # ★ 신규 feature (memos 모양 + snap 팔레트 재사용)
│ ├── types.ts # Snippet 로컬 타입(서버 DTO 아님)
│ ├── api/snippets.api.ts # 데이터 인터페이스(브릿지 백엔드). list/create/update/delete/recordUse
│ ├── core/search.ts # 순수 검색(키워드 AND, name+desc, 대문자) — 단위테스트
│ ├── core/ranking.ts # usage 정렬 (-count,-lastUsed,name) — 단위테스트
│ ├── hooks/useSnippets.ts # react-query (SNIPPETS_KEY·invalidate·토스트)
│ ├── components/ # SnippetRow·PreviewPane·CategoryChips·EditDialog (Raycast 리스킨)
│ └── pages/SnippetPalettePage.tsx # snap SessionListPage 복제 + Raycast 룩 + Enter→pasteToApp
├── lib/bridge/
│ ├── webviewBridge.ts # (기존) + C#→JS 리스너 확장
│ ├── snippetBridge.ts # ★ 신규: 요청-응답(reqId) 래퍼 — snippets.api 가 사용
│ └── bridgeNavigate.ts # ★ 신규: C#→JS navigate 수신 → react-router 이동
├── config/routes.ts # + PATHS.SNIPPET
└── routes.tsx # + SnippetPalettePage 라우트(SnapLayout 그룹)
3_windowsApp/
├── CodeAssist.Shell/
│ ├── Storage/SnippetRepository.cs # ★ 신규: 003 repository.py+usage.py 포팅(Microsoft.Data.Sqlite)
│ ├── Storage/SnippetSeed.cs # ★ 신규: 첫 실행 시 003 snippets.db 1회 복사
│ ├── Platform/HotKeyService.cs # 확장: 멀티 핫키(id별 등록/디스패치)
│ └── CodeAssist.Shell.csproj # + Microsoft.Data.Sqlite
├── CodeAssist.App/
│ ├── App.xaml.cs # 확장: Ctrl+1 두 번째 핫키 + WndProc wParam 전달
│ └── Views/WebHostView.xaml.cs # 확장: snippets.* 브릿지 핸들러 + navigate 송신(SendToWeb)
└── CodeAssist.Tests/ # + SnippetRepository 테스트
```
**Structure Decision**: 기존 2폴더 구조(2_frontend feature 모듈 + 3_windowsApp WPF/Shell) 유지. 신규 코드는 위 ★ 표시. 새 프로젝트/레이어 없음.
## Complexity Tracking
> Constitution 위반 없음. 편차 2건은 §Constitution Check에 정당화(로컬 SQLite라 HTTP 클라이언트 미경유 / 로컬 엔티티라 feature-local 타입). 별도 정당화 표 불필요.
+49
View File
@@ -0,0 +1,49 @@
# Quickstart: 스니펫 팔레트 검증
end-to-end로 "돌아간다"를 확인하는 가이드. 구현 코드는 tasks.md/구현 단계에서.
## 전제
- Windows + WebView2 런타임.
- `3_windowsApp` 빌드 가능(.NET SDK), `2_frontend` `npm install` 완료.
- DEBUG 실행은 WebView2 안에서 vite(localhost:15173)를 자동으로 물어 띄움 → 브릿지 살아있음.
## 실행
```pwsh
# 데스크톱 런처(DEBUG) — WebView2 안에 React dev 앱 + C# 호스트
# 3_windowsApp 솔루션 빌드/실행 (CodeAssist.App)
```
## 단위 검증 (호스트 없이)
```pwsh
# 프론트: 검색/랭킹 순수함수 + 브릿지 mock
cd 2_frontend; npm run test # core/search, core/ranking, snippets.api(브릿지 mock)
# C#: SnippetRepository CRUD/usage (temp sqlite)
dotnet test 3_windowsApp/CodeAssist.Tests
```
## end-to-end 시나리오 (수동)
각 시나리오는 spec의 Acceptance를 눈으로 확인.
1. **소환·진입** (US1-1): 메모장에 포커스 → `Ctrl+1` → 팔레트가 가운데 뜨고 `/snippet`으로 진입, 검색 자동포커스, 결과는 usage 순.
2. **검색** (US1-2): 키워드 입력 → 공백 나눈 키워드 전부가 name+desc에 있는 것만 남음(대소문자 무시, body 매칭 안 됨).
3. **프리뷰** (US1-3): `↑`/`↓` → 우측 프리뷰가 선택 스니펫 body를 원문 그대로(공백/줄바꿈/특수문자) 표시.
4. **붙여넣기** (US1-4): `Enter` → 팔레트 사라지고 **메모장(직전 창)에** body가 정확히 삽입, 그 스니펫 usage 상승.
5. **빠른 붙여넣기** (US1-5): `Alt+3` → 3번 행이 선택 이동 없이 즉시 붙여넣어짐.
6. **Esc 단계** (US1-6): 검색어 있을 때 `Esc` → 비움 / 빈 상태에서 `Esc` → 숨김.
7. **생성** (US2-1~3): 코드 복사 후 생성 → body가 클립보드로 채워짐 / 이름·내용 빈 채 저장 거부 / 중복 이름 거부.
8. **편집·삭제** (US2-4~6): `F2`/더블클릭 → 이름 읽기전용, 수정 저장 / 편집창서 삭제+확인 → 사라짐 / 완료 후 목록 새로고침.
9. **분류** (US3): category 칩 선택 → 그 분류만 / `Ctrl+←/→` 칩 이동.
10. **영속** (SC-003): 앱 껐다 켜기 → 스니펫·usage 유지. **첫 실행 시** 003 `snippets.db` 있으면 그 데이터로 시작(시드), 없으면 빈 목록.
11. **대상 정확성** (SC-006): 서로 다른 창에서 각각 `Ctrl+1`→붙여넣기 → 항상 "부르기 직전 창"에 들어감.
## 참조
- 메시지 계약: [contracts/bridge-messages.md](./contracts/bridge-messages.md)
- 데이터 인터페이스: [contracts/snippet-data-interface.md](./contracts/snippet-data-interface.md)
- 데이터 모델/스키마: [data-model.md](./data-model.md)
- 결정 근거: [research.md](./research.md)
+68
View File
@@ -0,0 +1,68 @@
# 완료 보고: 스니펫 팔레트 (001-snippet-palette)
**브랜치**: `feat/snippet-palette` · **작성**: 2026-08-05 · **상태**: 최종 승인 대기
003(파이썬 winkit-snippet)의 스니펫 검색·붙여넣기 기능을 이 앱(React SPA + .NET WebView2)에 재개발 이식. 전역 핫키 `Ctrl+1` → Raycast 결 팔레트 → 검색 → 앞 창에 붙여넣기. 저장은 C# 호스트 소유 로컬 SQLite.
## 계획 vs 결과
| Phase / Story | 계획 | 결과 |
|---|---|---|
| Foundational (T001-T011) | 멀티핫키·Ctrl+1·SendToWeb/navigate·SnippetRepository+스키마·시드·브릿지 왕복·라우트 | ✅ 전부. C# 10→17 테스트, 프론트 브릿지 왕복 테스트 |
| US1 / MVP (T012-T024) | 검색(키워드 AND, name+desc)·usage 랭킹·Enter/Alt 붙여넣기·프리뷰·Raycast 팔레트 | ✅ 전부. 검색/랭킹 순수함수 + api 테스트 |
| US2 / CRUD (T025-T031) | 생성/편집/삭제·EditDialog(클립보드 프리필)·검증(중복·빈값 거부) | ✅ 전부. repo CRUD xUnit + api 테스트 |
| US3 / category (T032-T034) | category 칩 필터 + Ctrl+←/→ | ✅ 전부. (검색 category 필터는 US1때 이미 구현돼 있어 칩 UI만 추가) |
| Polish (T035-T037) | 최종 검증·quickstart·report | T035 ✅ / T036 데스크톱 수동확인(사용자) / T037 이 문서 |
## 신규 vs 재사용 (재사용 우선 원칙)
**신규(핵심 3곳만):**
- C# 2번째 전역 핫키(멀티핫키로 `HotKeyService` 확장) + Ctrl+1
- C#→JS `navigate` 푸시(`SendToWeb`/`PostWebMessageAsJson`)
- React↔SQLite reqId 요청-응답 브릿지 + C# `SnippetRepository`/`SnippetBridge`/`SnippetSeed`
**재사용:**
- 팔레트 UI: snap `SessionListPage`(키보드 nav·IME 안전·Esc 단계) 복제 후 Raycast 리스킨
- 붙여넣기: 기존 `pasteToApp``PasteService`(앞 창 Ctrl+V) 그대로
- 핫키/트레이/단일실행/앞창 캡처, memos feature/react-query 패턴
## 문서화한 정당 편차
1. 데이터가 `@/lib/api/client`(HTTP) 미경유 — 로컬 SQLite라 브릿지 백엔드. react-query는 유지.
2. `Snippet` 타입이 `types/api.ts`(서버 DTO) 아닌 feature-local — 로컬 엔티티(snap 방식과 동일).
## 계획과 달라진 점 / 겪은 문제
- **시드 소스 부재**: 003의 `snippets.db`가 이 머신에 실제로 없음(포터블/미실행) → 첫 실행 시 빈 DB로 시작(설계상 fallback). 003 데이터 이어받기는 그 파일이 생기면 자동 동작.
- **git index 레이스**(Foundational): 프론트·C# implementer를 병렬로 돌렸다가 같은 git index 충돌로 커밋이 섞이고 일부 미커밋됨. working tree(진실원천)로 검증·복구(30159d2). 이후 **implementer 순차 원칙** 확립.
- **클릭 모델 정리**(US2 리뷰): US1이 행 단일클릭=붙여넣기로 만들어 더블클릭 편집이 죽어있었음 → 단일클릭=선택, Enter/Alt=붙여넣기, 더블클릭/F2=편집(003·spec 정합).
- **콜드스타트 navigate 드롭**(Foundational 리뷰): 앱 시작 직후 첫 Ctrl+1이 유실될 수 있어 pending 버퍼+NavigationCompleted flush로 방어.
## 검증
- 프론트: **233/233** 통과, build OK. 스니펫 feature lint **0문제**. (repo 전체 20 lint 문제는 무관 feature의 pre-existing 빚.)
- C#: **17/17** 통과, build 0 경고/에러.
- 런타임 E2E(Ctrl+1→검색→붙여넣기, 시드, 붙여넣기 대상 정확성): **데스크톱 앱 실행 필요 — 헤드리스 불가. 사용자 수동 확인 권장**(quickstart.md 시나리오 1~11).
## 남은 것 / 이월(deferred)
- **[수동확인]** quickstart 시나리오 — 실제 데스크톱에서.
- **[deferred]** 붙여넣기 실패해도 recordUse 됨(기존 `paste.code` fire-and-forget 프로토콜 한계, ack 추가는 별개 작업).
- **[deferred]** list-fetch 에러가 빈상태와 동일 표시(에러 UI 없음).
- **[deferred]** `HotKeyService.Dispose` 단일 `_hwnd` 가정(현재 두 핫키 같은 hwnd라 무해).
- **[deferred]** `SnippetBridge` reqId 빈값 시 promise 영구 대기(정상 caller는 항상 reqId 세팅).
## 최종 리뷰 (whole-branch, opus)
**APPROVE** — 보안(전 쿼리 파라미터화, body는 `<pre>` escape)·정합성(delete가 usage까지·name 정규화 일관)·브릿지 상관(reqId 유일·리스너 멱등)·동시성(WebView2 UI 스레드 단일) 다 클린. Critical/Important 신규 없음.
리뷰서 나온 신규 Minor 4개는 **1회 fix wave로 처리 완료**(재리뷰 ALL ADDRESSED):
- **M1/M2**: `/snippet``SnapLayout``ProtectedRoute` 직속 `h-screen` 풀스크린으로 이동 → snap 헤더 없이 Raycast 풀윈도 룩 복구 + Ctrl+N 이탈 차단. auth 유지(routes.test.tsx 회귀가드).
- **M3**: navigate 버퍼는 pre-ready일 때만 → reload 시 stale 재전송 방지.
- **M4**: 비도메인 예외는 raw 메시지 대신 generic 한글 토스트(SQLite 영어 내부 노출 차단).
**런타임 미검(헤드리스 한계 — 사용자 확인 필요):** 실제 데스크톱에서 Ctrl+1 → 팔레트 풀윈도 중앙 표시(snap 헤더 없음)·검색·앞 창 붙여넣기·Ctrl+N 비이탈. quickstart.md 시나리오로 눈확인 권장.
**최종 검증:** 프론트 **234/234**, C# **17/17**, 빌드 클린. 스니펫 코드 lint 0문제.
**결론:** 문서화된 deferral(위 "남은 것") 제외하고 **merge 준비 완료**.
+82
View File
@@ -0,0 +1,82 @@
# Research: 스니펫 팔레트 (Phase 0)
브레인스토밍·clarify에서 큰 결정은 끝났고(저장=로컬 SQLite, 핫키=Ctrl+1, UI=Raycast, 데이터=003 시드), 여기선 **구현 미해결점**만 결정한다.
---
## R1. React ↔ SQLite 브릿지: 요청→응답 방식
**결정**: 기존 postMessage 채널을 **reqId 상관관계(correlation) 왕복**으로 확장. `AddHostObjectToScript`(COM 호스트 객체) 안 씀.
- JS→C#: `{type:"snippets.list", reqId}` 식으로 요청(각 호출마다 증가하는 reqId).
- C#→JS: `Web.CoreWebView2.PostWebMessageAsJson({type:"snippets.result", reqId, ok, data|error})`로 응답.
- JS: `Map<reqId, {resolve,reject}>` 유지, `chrome.webview.addEventListener("message")`에서 매칭 reqId를 resolve.
**Rationale**:
- 기존 브릿지가 이미 postMessage(단방향)라 **같은 메커니즘 하나로 통일** — 스타일 일관, 학습비용 0.
- COM 마샬링(hostObjects) 회피: 배열/객체 반환 시 JSON 문자열 마샬링이 번거롭고, 기존 코드와 이질적.
- SQLite 호출은 로컬·즉각이라 C#에서 동기로 처리 후 바로 응답 → async 복잡도 불필요(UI 스레드에서 안전, 기존 paste도 UI 스레드).
**Alternatives 기각**:
- `AddHostObjectToScript`: 호출부는 깔끔(`await hostObjects.snippets.list()`)하나 COM 세팅·마샬링·기존 스타일 이탈.
- 별도 로컬 HTTP 서버(C#): 과함(YAGNI). 브릿지로 충분.
## R2. C#→JS 방향(navigate 신호)
**결정**: `CoreWebView2.PostWebMessageAsJson`로 C#→JS 푸시. JS는 `chrome.webview.addEventListener("message", …)`로 수신. 이 채널을 (a) 스니펫 핫키 시 `{type:"navigate", path:"/snippet"}`와 (b) R1의 `snippets.result` 양쪽에 공용.
- `WebHostView``SendToWeb(object msg)` 추가(`PostWebMessageAsJson(JsonSerializer.Serialize(msg))`).
- 프론트 `bridgeNavigate.ts`: navigate 메시지 수신 → 등록된 react-router `navigate` 콜백 호출. React 최상위에서 mount 시 콜백 등록.
**Rationale**: WebView2 기본 제공 기능, 의존성 0. 팔레트는 부팅 시 `/snap`으로 뜨므로(WebHostView `StartPath`), 스니펫은 이 navigate로 진입.
**주의**: 여러 `addEventListener` 등록 가능 → `snippetBridge`(결과용)와 `bridgeNavigate`(내비용)가 각자 리스너를 걸고 `type`으로 필터. 서로 결합 안 함.
## R3. 멀티 핫키(C#)
**결정**: `HotKeyService`를 **id별 다중 등록**으로 확장.
- 현재: 단일 `HotKeyId=0x1000`, 단일 `HotKeyPressed`(Action), `ProcessMessage(int msg)`**wParam(=핫키 id)을 무시**.
- 변경:
- `Register(int id, uint mods, uint vk)` — 등록 id를 인자로, 내부 `HashSet<int>`로 추적, Dispose 시 전부 Unregister.
- `event Action<int>? HotKeyPressed` — 눌린 id 전달.
- `ProcessMessage(int msg, IntPtr wParam)` — wParam.ToInt32()가 핫키 id. `App.WndProc`가 wParam을 넘기도록 수정.
- `App`: id `0x1000`=snap 토글(Ctrl+Alt+Space, 기존), `0x1001`=스니펫(Ctrl+1). 등록 실패해도 죽지 말고 트레이 안내(기존 패턴).
**Ctrl+1 상수**: `MOD_CONTROL(0x2)`, `VK_1(0x31)`. **핫키 조합은 App 상수 한 곳**에 모아 나중에 바꾸기 쉽게(spec Edge: 전역 Ctrl+1 가로채기 감수).
**스니펫 핫키 동작**: 눌리면 `ShowPalette()`(앞창 캡처+표시, 기존) 후 `SendToWeb({type:"navigate", path:"/snippet"})`. 이미 /snippet에서 보이는 상태면 토글로 숨김(선택, 구현 단순하면 적용).
## R4. SQLite 라이브러리·DB 경로
**결정**:
- 라이브러리: **`Microsoft.Data.Sqlite`** (경량·MS 공식) 를 `CodeAssist.Shell.csproj`에 추가. (현재 리포에 SQLite 참조 전무.)
- DB 경로: `%LOCALAPPDATA%\CodeAssist\snippets.db` (쓰기 가능·안정. WebView2 UserDataFolder(temp)와 별개 — temp는 캐시성).
- 스키마: 003 그대로 — `snippets(name PK, desc, body, category)`, `usage(name PK, count, last_used)`. (data-model.md 참조.)
- 저장 방식: 003의 통짜 replace 대신 **행 단위 upsert/delete**로(개별 CRUD API라 그게 자연스러움). 랭킹·검색은 프론트가 하므로 C# repo는 순수 CRUD + usage upsert만.
**Rationale**: MS 공식·의존성 가벼움. `SQLitePCLRaw` 번들 포함이라 네이티브 배포도 무난.
## R5. 첫 실행 시드(003 데이터 1회 복사)
**결정**: `SnippetSeed` — 앱 시작 시 대상 DB(`%LOCALAPPDATA%\CodeAssist\snippets.db`)가 **없을 때만**, 003의 `snippets.db`가 알려진 경로에 있으면 그 파일을 대상 경로로 복사. 있으면(=이미 시드됨) 아무것도 안 함. 003 파일이 없으면 빈 DB로 시작(에러 아님).
- **003 경로 확인은 구현 시점 lookup**: 003(winkit)의 데이터 디렉토리 규칙(`winkit.shell` 데이터 폴더 + `snippets.db`)을 그때 확인해 상수화. 못 찾으면 빈 DB로 폴백(기능 안 막음).
- 파일 복사 한 번이라 "임포트 기능"급 작업 아님(spec FR-024).
## R6. 프리뷰 렌더링
**결정**: 프리뷰·body 표시는 **순수 `<pre>` 고정폭**(공백·줄바꿈 원문 보존). `react-markdown` **안 씀**.
**Rationale**: body는 마크다운이 아니라 **날 코드**다. 마크다운 파싱하면 특수문자(`*`,`#`,`` ` ``)가 깨지고 붙여넣을 원문과 화면이 달라짐. `<pre>`가 요구사항(원문 그대로·고정폭·no-wrap)을 정확히 만족. → docs-lib(react-markdown) 트리거 안 함.
## R7. 순수 브라우저(dev) 폴백
**결정**: 스니펫 기능은 **데스크톱(WebView2) 전용**. 순수 브라우저에서 `isWebView()===false`면 `snippets.api`는 빈 목록/명확한 비활성 상태를 반환(throw 대신 graceful). 붙여넣기·핫키·SQLite 모두 호스트가 필요하므로 순수 브라우저 지원은 비목표.
**Rationale**: DEBUG 개발도 WebView2 안에서 vite를 물어 돌리므로(WebHostView가 localhost:15173을 WebView2로 네비) 브릿지가 살아있음 → 개발 지장 없음. 순수 크롬 탭 지원은 YAGNI.
## 미해결/구현 시점 확인 목록
- 003 `snippets.db` 실제 경로(R5) — winkit 데이터 디렉토리 규칙 확인 후 상수화.
- `Microsoft.Data.Sqlite` 버전 핀 — 구현 시 최신 안정.
+192
View File
@@ -0,0 +1,192 @@
# Feature Specification: 스니펫 팔레트 (Snippet Palette)
**Feature Branch**: `001-snippet-palette`
**Created**: 2026-08-05
**Status**: Draft
**Input**: 003(winkit-snippet, 파이썬 PySide6)의 snippet 기능을 021.code-assistant-v2(React SPA + .NET WebView2 데스크톱 런처)에 재개발해 이식. snap과 별도 route(`/snippet`), 동일 아키텍처. 저장은 로컬 SQLite(003 스키마 그대로, C# 호스트가 소유). 전용 전역 핫키 Ctrl+1로 소환. UI/UX는 Raycast 결.
## 2026-09-11 확정: 단계별 스니펫 런처
이번 사용자 승인으로 아래 초기 초안의 항상 열린 목록·자동 프리뷰·Enter 붙여넣기 설명은 이 절로 대체함. 미완료 CRUD·사용기록 저장은 이번 범위에 포함하지 않음.
- 기준 앱은 Tauri(`specs/004-tauri-shell/`), 소환은 `Ctrl+Shift+7`. 클린 블루와 기존 Enter 원문 복사를 유지함.
- 처음 소환하면 검색창만 표시. 공백만 입력한 경우도 결과·분류·프리뷰를 숨김.
- 검색어 입력 시 목록과 분류·하단 힌트만 표시. 첫 행을 자동 선택하지 않음.
- `↓`는 첫 행, `↑`는 마지막 행부터 선택. 이후 화살표·행 클릭으로 선택한 코드만 오른쪽에 표시.
- 프리뷰에는 코드·문법 색상·옅은 줄 번호만 표시. 이름·설명·분류 중복, 장식 점, 코드 번호·언어 헤더, 박스 테두리·그림자는 제거하고 세로 스크롤은 하나만 둠.
- 복사·앱에 붙여넣기는 하단 액션바에 모음. 복사 버튼은 창을 유지하고, 검색창/선택 행 Enter는 기존처럼 복사 후 닫음. 버튼에 포커스한 Enter는 해당 버튼 동작을 실행함. 챗봇 코드 카드는 기존 모양을 유지함.
- 검색어·분류 변경 시 선택을 해제. `Esc`로 검색을 지우면 검색창에 포커스를 돌려주고, 빈 검색에서 `Esc`는 창을 숨김.
- 선택한 행에서 `Enter`로 원문을 복사. 미선택·IME 조합 중에는 복사하지 않음.
- 선택한 코드의 `Ctrl+Enter`는 기존 앱에 붙여넣기 버튼과 같은 경로를 실행함. 데스크톱에서만 사용하고 미선택·IME 조합·편집 중·자동 반복·추가 modifier 조합은 무시함.
- 붙여넣기는 누른 키를 놓은 뒤 대상 창을 확인하고 실행. 대상 없음·창 전환 실패·키 해제 시간 초과 때는 창과 오류 안내를 유지하며, 이미 복사한 원문은 클립보드에 남김.
- 실제 창의 콘텐츠 크기: 검색 `640×84`, 결과 `640×440`, 프리뷰 `840×520`, 편집 `960×600` 논리 픽셀. DPI와 모니터 작업 영역·바깥 테두리를 반영해 화면 안에 맞춤.
- 챗봇 복귀와 종료 때 원래 창 크기·위치를 복원. 최대화 전 일반 크기도 보존하며, 축소 크기를 다음 시작 크기로 저장하지 않음.
- 재소환은 검색어·분류·선택·편집/삭제 확인을 닫고 검색부터 다시 시작함.
---
## 개요
자주 쓰는 코드 조각(스니펫)을 전역 핫키로 언제든 불러, 몇 글자 검색하고 방금까지 쓰던 창에 바로 붙여넣는 기능. 003(파이썬)에 있던 걸 이 앱(React + WebView2) 구조로 다시 만든다. 핵심 동작은 003 그대로, 화면은 Raycast 커맨드 팔레트 느낌으로.
**디자인 방향(Raycast):** 가운데 떠있는 프레임리스 커맨드바 — 맨 위 큰 검색 입력, 그 아래 결과 리스트(행마다 제목 + 부제 + 우측 액세서리), 우측에 선택 항목 프리뷰, 맨 아래 액션 힌트바(왼쪽 컨텍스트 / 오른쪽 `⏎ 붙여넣기` 등). 키보드 우선, 어두운 톤·둥근 모서리.
---
## Clarifications
### Session 2026-08-05
- Q: v1 시작 시점의 데이터 소스(빈 DB / 003 파일 1회 복사 / 003 파일 직접 공유) → A: 첫 실행 시 003의 `snippets.db`를 앱 데이터 폴더로 1회 복사(시드) 후 독립 운영.
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - 핫키로 검색해서 앞 창에 붙여넣기 (Priority: P1)
다른 앱(에디터·터미널 등)에서 작업하다 `Ctrl+1`을 누르면 스니펫 팔레트가 화면 가운데 뜬다. 검색어를 몇 글자 치면 결과가 실시간으로 좁혀지고, 자주·최근 쓴 것이 위에 온다. `Enter`로 선택하거나 `Alt+1~9`로 번호 매긴 행을 바로 골라 붙여넣으면, 팔레트가 사라지고 **방금 전까지 쓰던 창에 스니펫 내용이 그대로 들어간다.**
**Why this priority**: 이게 이 기능의 존재 이유. 이거 하나만 돌아도 도구로서 가치가 있음(스니펫은 미리 넣어둔 상태 가정). 나머지는 다 이걸 돕는 것.
**Independent Test**: 스니펫 몇 개가 저장된 상태에서, 아무 텍스트 에디터에 포커스 → `Ctrl+1` → 검색어 입력 → `Enter` → 에디터에 스니펫 body가 원문 그대로 삽입되는지로 단독 검증 가능.
**Acceptance Scenarios**:
1. **Given** 에디터에 커서가 있고 스니펫이 여러 개 저장돼 있음, **When** `Ctrl+1`을 누름, **Then** 화면 가운데에 검색 입력에 포커스된 팔레트가 뜨고 결과가 usage 순으로 보인다.
2. **Given** 팔레트가 떠있음, **When** 검색어를 입력, **Then** 공백으로 나눈 키워드가 **전부** `name`+`desc`(대소문자 무시)에 들어있는 스니펫만 남고, usage 순으로 정렬된다.
3. **Given** 결과가 보임, **When** `↑`/`↓`로 이동, **Then** 선택 행이 바뀌고 우측 프리뷰가 그 스니펫의 body를 보여준다.
4. **Given** 원하는 행이 선택됨, **When** `Enter`, **Then** 팔레트가 사라지고 직전 활성 창에 그 스니펫 body가 붙여넣어지며, 그 스니펫의 usage(횟수+최근시각)가 올라간다.
5. **Given** 결과 상위 9개가 `1~9` 번호로 표시됨, **When** `Alt+3`, **Then** 3번 행이 선택 이동 없이 즉시 붙여넣어진다.
6. **Given** 검색어가 있음, **When** `Esc`, **Then** 검색어가 먼저 비워지고, 이미 비어있으면 팔레트가 숨는다.
---
### User Story 2 - 스니펫 만들고 고치고 지우기 (Priority: P2)
팔레트에서 새 스니펫을 만들거나(주로 방금 복사한 코드를 등록), 기존 걸 고치거나 지운다. 새로 만들 때 body는 현재 클립보드 내용으로 미리 채워진다.
**Why this priority**: 스니펫이 있어야 P1이 쓸모 있음. 다만 초기엔 수동/시드로 채워도 되니 P1보단 뒤.
**Independent Test**: 팔레트에서 생성 버튼/키 → 이름·내용 입력 → 저장 → 검색에 새 스니펫이 뜨는지. 편집·삭제도 같은 식으로 단독 검증.
**Acceptance Scenarios**:
1. **Given** 팔레트가 떠있고 클립보드에 코드가 있음, **When** 생성 액션, **Then** 편집 다이얼로그가 열리고 body가 클립보드 내용으로 채워지며 category 기본값은 "코드".
2. **Given** 생성 다이얼로그, **When** 이름 비움 또는 body 비움으로 저장 시도, **Then** 저장이 거부되고 뭐가 문제인지 알려준다.
3. **Given** 생성 다이얼로그, **When** 이미 있는 이름으로 저장 시도, **Then** 중복이라 거부된다.
4. **Given** 결과에서 한 행 선택, **When** `F2` 또는 더블클릭, **Then** 편집 다이얼로그가 열리고 이름은 읽기전용(=키), 나머지는 수정 가능.
5. **Given** 편집 다이얼로그, **When** 삭제 액션 + 확인, **Then** 그 스니펫이 지워지고 목록에서 사라진다.
6. **Given** 저장/수정/삭제 완료, **When** 다이얼로그 닫힘, **Then** 팔레트 목록이 현재 검색어 기준으로 새로고침된다.
---
### User Story 3 - category 칩으로 좁혀보기 (Priority: P3)
결과가 많을 때 category(전체/코드/…) 칩으로 한 분류만 추려본다.
**Why this priority**: 편의 기능. 검색만으로도 대부분 커버됨.
**Independent Test**: 서로 다른 category의 스니펫이 있을 때, 특정 칩 선택 → 그 category 것만 남는지.
**Acceptance Scenarios**:
1. **Given** 여러 category의 스니펫, **When** "코드" 칩 선택, **Then** category가 "코드"인 것만 검색 대상이 된다.
2. **Given** 칩바에 포커스 없이 팔레트가 떠있음, **When** `Ctrl+←`/`Ctrl+→`, **Then** 선택 칩이 좌우로 바뀐다.
3. **Given** "전체" 칩, **When** 선택, **Then** 모든 category가 검색 대상이 된다.
---
### Edge Cases
- **붙여넣기 실패**(직전 창 포커스 복원 실패): 스니펫 내용을 클립보드에 남겨 사용자가 수동으로 붙일 수 있게 한다(기존 붙여넣기 배선의 fallback 준용).
- **검색 결과 0개**: 빈 상태 안내를 보여준다(에러 아님).
- **`Alt+숫자`가 결과 개수보다 큼**: 무시.
- **IME 조합 중 `Enter`**: 조합 확정만 되고 붙여넣기는 일어나지 않는다.
- **전역 `Ctrl+1` 충돌**: `Ctrl+1`은 전역이라 다른 앱의 `Ctrl+1`을 가로챈다. 감수하는 선택. 단 핫키는 한 곳(config 상수)에서 바꿀 수 있어야 한다.
- **로컬 저장 불가/용량 초과**: 저장 실패를 사용자에게 알리고, 최소한 이번 세션 동작은 막지 않는다.
- **스니펫 0개 상태에서 소환**: 빈 목록 + "스니펫을 만들어보라"는 안내를 보여준다.
- **첫 실행 시 003의 `snippets.db`가 없음**: 시드를 건너뛰고 빈 DB로 시작(에러 아님).
---
## Requirements *(mandatory)*
### Functional Requirements
**소환 · 표시**
- **FR-001**: 전역 핫키 `Ctrl+1`로 스니펫 팔레트를 소환할 수 있어야 한다. 이 핫키는 snap 소환(`Ctrl+Alt+Space`)과 별개다.
- **FR-002**: 소환 시, 팔레트가 뜨기 **직전에 활성이던 창**을 붙여넣기 대상으로 기억해야 한다.
- **FR-003**: 팔레트는 화면 가운데 떠서 검색 입력에 자동 포커스되며, snap과 **다른 경로**(`/snippet`)로 열린다.
- **FR-004**: 팔레트는 Raycast식 레이아웃을 따른다 — 상단 검색 입력, 아래 결과 리스트(행: 제목=name, 부제=desc, 우측 액세서리=번호/category 등), 우측 프리뷰 패널, 하단 액션 힌트바.
**검색 · 랭킹**
- **FR-005**: 검색어를 공백으로 나눈 키워드가 **전부(AND)** 스니펫의 `name`+`desc` 합친 문자열(대소문자 무시)에 포함될 때만 결과에 포함한다. `body`는 검색 대상이 아니다. 퍼지/오타 보정 없음.
- **FR-006**: 검색어가 비어있으면 (선택된 category의) 전체 스니펫을 보여준다.
- **FR-007**: 모든 결과는 usage 순 — 사용횟수 내림차순, 같으면 최근 사용시각 내림차순, 그것도 같으면 name 오름차순 — 으로 정렬한다.
- **FR-008**: 상위 9개 결과에 `1~9` 번호를 매겨 표시한다.
**붙여넣기 · usage**
- **FR-009**: `Enter`는 현재 선택 행을, `Alt+1~9`는 해당 번호 행을 (선택 이동 없이) 즉시 붙여넣는다.
- **FR-010**: 붙여넣기는 FR-002에서 기억한 대상 창에 스니펫 `body`**원문 그대로(공백·줄바꿈 보존)** 삽입한다. (기존 WebView2 붙여넣기 배선 재사용.)
- **FR-011**: 붙여넣기 성공 시 해당 스니펫의 사용횟수 +1, 최근 사용시각을 현재로 갱신하고, 팔레트를 숨긴다.
**키 조작**
- **FR-012**: `↑`/`↓`로 선택 이동, 이동 시 우측 프리뷰가 갱신된다.
- **FR-013**: `Esc`는 단계 처리 — 검색어가 있으면 비우고, 없으면 팔레트를 숨긴다.
- **FR-014**: 프리뷰는 선택 스니펫의 `body`를 읽기전용·고정폭으로, 원문 그대로 보여준다.
**관리(CRUD)**
- **FR-015**: 새 스니펫을 만들 수 있어야 하며, 생성 시 body 초기값은 현재 클립보드 내용, category 기본값은 "코드"다.
- **FR-016**: 저장은 name과 body가 모두 비어있지 않을 때만 허용한다. name은 정규화(대문자화, 공백→`_`)되어 **유일**해야 하며 중복은 거부한다.
- **FR-017**: 기존 스니펫을 편집(`F2`/더블클릭)할 수 있고, 이때 name은 읽기전용(=식별 키)이다.
- **FR-018**: 스니펫을 삭제(편집 다이얼로그 안, 확인 후)할 수 있다.
- **FR-019**: 생성/편집/삭제 후 팔레트 목록은 현재 검색어 기준으로 새로고침된다.
**분류**
- **FR-020**: category 칩("전체" + 존재하는 category들)으로 검색 대상을 한 분류로 좁힐 수 있다. `Ctrl+←`/`Ctrl+→`로 칩 이동.
**저장**
- **FR-021**: 스니펫과 usage 정보는 로컬에 영구 저장되어 앱을 재시작해도 유지된다.
- **FR-022**: 저장 접근은 하나의 데이터 인터페이스 뒤에 숨겨, 저장 방식을 바꿔도 화면·검색·랭킹 로직이 영향받지 않아야 한다.
- **FR-023**: 저장 스키마는 003의 것을 그대로 따른다 — 스니펫 테이블(name PK, desc, body, category)과 별도 usage 테이블(name PK, count, last_used).
- **FR-024**: 앱 데이터 폴더에 DB가 아직 없고 003의 `snippets.db`가 존재하면, 첫 실행 시 그 파일을 앱 데이터 폴더로 1회 복사해 시드한다. 없으면 빈 DB로 시작한다. 이후엔 두 파일이 독립이다.
### Key Entities
- **Snippet**: 붙여넣을 코드 조각 한 개.
- `name` — 식별 키(정규화·유일), 결과 행의 제목
- `desc` — 짧은 설명, 결과 행 부제 · 검색 대상
- `body` — 실제 붙여넣어지는 내용(원문 그대로 보존)
- `category` — 분류 라벨(기본 "코드")
- `usageCount` — 붙여넣기 성공 누적 횟수
- `lastUsed` — 마지막 붙여넣기 시각
- (003의 `change_word1/2`는 안 쓰는 유물이라 가져오지 않음)
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 핫키를 누른 뒤 원하는 스니펫을 붙여넣기까지 몇 초 안(핫키 → 검색어 몇 글자 → `Enter`/`Alt+숫자`)에 끝난다.
- **SC-002**: 자주 쓰는 스니펫이 검색·기본 목록 상위에 노출되어, 검색어 없이도 상위 몇 개 안에서 고를 수 있다.
- **SC-003**: 앱을 껐다 켜도 저장된 스니펷과 usage(횟수·최근시각)가 100% 유지된다.
- **SC-004**: 붙여넣어진 내용이 스니펫 body와 글자·공백·줄바꿈까지 완전히 일치한다.
- **SC-005**: 핫키 소환에서 팔레트가 보이기까지 체감상 즉시(수백 ms) 뜬다.
- **SC-006**: 붙여넣기 대상이 항상 "팔레트를 부르기 직전 창"이다(엉뚱한 창에 들어가지 않는다).
---
## Assumptions
- **저장은 v1에서 로컬 SQLite(단일 PC)**이며, C# 데스크톱 호스트가 DB 파일(`snippets.db`, 003과 동일 스키마)을 소유한다. 기기 간 동기화는 안 함. 데이터 인터페이스(FR-022)를 통해 나중에 백엔드로 교체할 여지만 남긴다.
- **첫 실행 시 003의 `snippets.db`를 앱 데이터 폴더로 1회 복사(시드)** 후 두 앱은 독립. 이후 003을 고쳐도 이 앱엔 영향 없음(그 반대도). 이건 파일 복사 한 번이라 별도 "임포트 기능"과 다름 — v1 범위 안.
- **붙여넣기·핫키·앞창 캡처는 기존 데스크톱 런처 배선을 재사용**한다(앞 창에 코드 붙여넣는 기능은 이미 있음). 신규 배선은 (1) 스니펫 전용 핫키 등록, (2) 소환 시 팔레트를 스니펫 경로로 이동시키는 신호, (3) React↔SQLite를 잇는 브릿지 데이터 메서드(요청→응답: 목록/생성/수정/삭제/사용기록)와 C# `SnippetRepository`(003 포팅)다.
- **팔레트 UI는 snap의 기존 커맨드 팔레트(키보드 네비게이션 등) 구조를 재사용**하되, 겉모습을 Raycast 결로 다시 입힌다.
- **003의 기존 `snippets.db` 데이터 임포트는 v1 범위 밖**(나중에 별도 임포트 수단으로).
- **백엔드 `/snippets` 동기화는 v1 범위 밖.**
- **전역 `Ctrl+1` 사용은 의도된 선택**이며, 다른 앱의 동일 단축키를 가로채는 점은 감수한다(핫키는 설정 한 곳에서 교체 가능).
+193
View File
@@ -0,0 +1,193 @@
---
description: "Task list for 스니펫 팔레트 (Snippet Palette)"
---
# Tasks: 스니펫 팔레트 (Snippet Palette)
**Input**: `specs/001-snippet-palette/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md)
**Tests**: 포함(이 프로젝트는 superpowers TDD RED→GREEN + `npm run test`/`dotnet test` 통과가 완료 기준 — CLAUDE.md §4).
**Organization**: user story별 phase. US1(P1)만 해도 MVP(스니펫 있으면 검색→붙여넣기 됨).
## Format: `[ID] [P?] [Story] Description (파일경로)`
- **[P]**: 다른 파일·의존 없음 → 병렬 가능
- **[US#]**: 해당 user story
- 핸드오프: 여기서부터 superpowers 뒷단(`executing-plans`/`subagent-driven-development` + `test-driven-development`). 단계(phase) 하나 끝날 때마다 `specs/001-snippet-palette/stage-N.md` 기록.
---
## Phase 1: Setup (공용 기반)
- [ ] T001 `Microsoft.Data.Sqlite` 패키지 추가 (`3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj`)
- [ ] T002 [P] `PATHS.SNIPPET = "/snippet"` 추가 (`2_frontend/src/config/routes.ts`)
- [ ] T003 [P] feature 폴더 뼈대 + 타입 생성: `Snippet`/`SnippetInput` (`2_frontend/src/features/snippets/types.ts`, data-model.md 준용)
---
## Phase 2: Foundational (모든 story 선행 — 반드시 먼저)
**목표 체크포인트**: `Ctrl+1` → 팔레트가 `/snippet`(빈 화면)로 뜨고, `snippets.api.list()`가 시드된 데이터를 왕복으로 돌려준다.
- [ ] T004 `HotKeyService` 멀티 핫키로 확장 — `Register(int id, mods, vk)`·`HashSet<int>` 추적·`event Action<int> HotKeyPressed`·`ProcessMessage(int msg, IntPtr wParam)` (`3_windowsApp/CodeAssist.Shell/Platform/HotKeyService.cs`) + `App.WndProc`가 wParam 전달하도록 수정 (`3_windowsApp/CodeAssist.App/App.xaml.cs`)
- [ ] T005 `Ctrl+1`(id `0x1001`, `MOD_CONTROL|VK_1`) 등록 + 눌리면 `ShowPalette()` 후 navigate 신호 송신. 핫키 조합 상수는 한 곳에 모음 (`3_windowsApp/CodeAssist.App/App.xaml.cs`) — depends T004
- [ ] T006 `WebHostView``SendToWeb(object)`(=`PostWebMessageAsJson`) 추가 + `OnWebMessageReceived``snippets.*``SnippetBridge`로 위임 + `navigate` 푸시 경로 (`3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs`) — depends T004/T005 (navigate 대상)
- [ ] T007 [P] `SnippetRepository` 뼈대 + 스키마 초기화(`CREATE TABLE snippets/usage`) + DB 경로 `%LOCALAPPDATA%\CodeAssist\snippets.db` (`3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`, data-model.md 스키마)
- [ ] T008 [P] `SnippetSeed` — 첫 실행 시 대상 DB 없고 003 `snippets.db` 있으면 1회 복사, 없으면 빈 DB. App 시작 시 repo 사용 전 호출 (`3_windowsApp/CodeAssist.Shell/Storage/SnippetSeed.cs`) — 003 경로는 구현 시 확인(research R5)
- [ ] T009 [P] 프론트 브릿지 왕복 인프라 — `reqId` Map + `chrome.webview` 메시지 리스너(`snippets.result` 매칭 resolve/reject), 순수 브라우저면 비활성 (`2_frontend/src/lib/bridge/snippetBridge.ts`, contracts/bridge-messages.md)
- [ ] T010 [P] `bridgeNavigate.ts``navigate` 메시지 수신 → 등록된 react-router navigate 콜백 호출. 앱 최상위에서 mount 시 콜백 등록 (`2_frontend/src/lib/bridge/bridgeNavigate.ts` + 등록 지점)
- [ ] T011 `/snippet` 라우트 + 임시 placeholder 페이지를 `SnapLayout` 그룹에 추가 (`2_frontend/src/routes.tsx`) — depends T002
**Checkpoint**: Ctrl+1 → /snippet 빈 화면 뜨고 list 왕복 확인.
---
## Phase 3: User Story 1 - 핫키로 검색→앞 창 붙여넣기 (P1) 🎯 MVP
**Goal**: 스니펫 검색·프리뷰·usage랭킹 + Enter/Alt로 앞 창에 붙여넣기.
**Independent Test**: 시드된 스니펫 상태에서 에디터 포커스 → Ctrl+1 → 검색 → Enter → 에디터에 body 원문 삽입 + usage 상승.
### Tests (RED 먼저)
- [ ] T012 [P] [US1] `core/search` 단위테스트 — 키워드 AND, name+desc 대문자, body 제외, 빈쿼리 (`2_frontend/src/features/snippets/core/search.test.ts`)
- [ ] T013 [P] [US1] `core/ranking` 단위테스트 — `(-count,-lastUsed,name)` 정렬 (`2_frontend/src/features/snippets/core/ranking.test.ts`)
- [ ] T014 [P] [US1] `SnippetRepository.List/RecordUse` xUnit (temp sqlite, usage 조인·upsert) (`3_windowsApp/CodeAssist.Tests/SnippetRepositoryTests.cs`)
- [ ] T015 [P] [US1] `snippets.api` list/recordUse — 브릿지 mock 테스트 (`2_frontend/src/features/snippets/api/snippets.api.test.ts`)
### Impl
- [ ] T016 [P] [US1] `core/search.ts` (검색 순수함수, category 파라미터 포함) (`2_frontend/src/features/snippets/core/search.ts`)
- [ ] T017 [P] [US1] `core/ranking.ts` (usage 정렬) (`2_frontend/src/features/snippets/core/ranking.ts`)
- [ ] T018 [US1] `SnippetRepository.List`(snippets⨝usage) + `RecordUse`(count+1,last_used=now) (`3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`)
- [ ] T019 [US1] `SnippetBridge` 핸들러 `snippets.list`·`snippets.recordUse` → repo 호출 후 `snippets.result` 응답 (`3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs` 또는 신규 SnippetBridge.cs)
- [ ] T020 [US1] `snippets.api.ts`(list, recordUse) + `useSnippets`/`useRecordUse` (SNIPPETS_KEY·invalidate, memos 패턴) (`2_frontend/src/features/snippets/api/snippets.api.ts`, `hooks/useSnippets.ts`)
- [ ] T021 [US1] `SnippetPalettePage` — snap `SessionListPage` 복제 → 검색입력 + 결과리스트 + 우측 프리뷰, Raycast 룩 리스킨 (`2_frontend/src/features/snippets/pages/SnippetPalettePage.tsx`)
- [ ] T022 [US1] 키 조작 — ↑↓ nav(프리뷰 갱신), Enter→`pasteToApp(body)`+`recordUse`+`hideWindow`, Alt+1~9 즉시 붙여넣기, Esc 단계(비움→숨김), IME 안전 (`SnippetPalettePage.tsx`)
- [ ] T023 [P] [US1] `SnippetRow`(제목/부제/우측 번호 액세서리) + `PreviewPane`(`<pre>` 고정폭·원문보존, react-markdown 안 씀) Raycast 결 (`2_frontend/src/features/snippets/components/`)
- [ ] T024 [US1] 빈 상태 안내 + 상위 9개 `1~9` 번호 표시 (`SnippetPalettePage.tsx`)
**Checkpoint**: US1 단독으로 완전 동작(MVP). **STOP & VALIDATE** — quickstart 1~6,10,11.
---
## Phase 4: User Story 2 - 생성/편집/삭제 (P2)
**Goal**: 팔레트에서 스니펫 CRUD. 생성 시 body는 클립보드로 프리필.
**Independent Test**: 생성→검색에 뜸 / 빈 이름·내용 거부 / 중복 이름 거부 / F2 편집(이름 읽기전용) / 편집창 삭제.
### Tests (RED 먼저)
- [ ] T025 [P] [US2] `SnippetRepository.Create/Update/Delete` xUnit — 중복 거부·정규화·검증 (`3_windowsApp/CodeAssist.Tests/SnippetRepositoryTests.cs`)
- [ ] T026 [P] [US2] `snippets.api` create/update/remove — 브릿지 mock(성공/`ok:false` reject) (`2_frontend/src/features/snippets/api/snippets.api.test.ts`)
### Impl
- [ ] T027 [US2] `SnippetRepository.Create/Update/Delete` — name 정규화·유일성·body 비어있지않음(위반 시 에러) (`3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`)
- [ ] T028 [US2] `SnippetBridge` 핸들러 create/update/delete (`ok:false`+한글 에러 계약) (`WebHostView.xaml.cs`/SnippetBridge.cs)
- [ ] T029 [US2] `snippets.api` create/update/remove + `useCreateSnippet`/`useUpdateSnippet`/`useDeleteSnippet`(invalidate + sonner 한글 토스트, ApiError 형태) (`api/snippets.api.ts`, `hooks/useSnippets.ts`)
- [ ] T030 [US2] `EditDialog` — 생성/편집 공용, 편집 시 name 읽기전용, 생성 시 body=클립보드(`navigator.clipboard.readText()`, 실패 시 빈값), 검증 (`2_frontend/src/features/snippets/components/EditDialog.tsx`)
- [ ] T031 [US2] 팔레트 배선 — 생성 액션(Ctrl+N/버튼), F2·더블클릭 편집, 다이얼로그 삭제+확인, 완료 후 목록 새로고침 (`SnippetPalettePage.tsx`)
**Checkpoint**: US1+US2 동작. quickstart 7~8.
---
## Phase 5: User Story 3 - category 칩 필터 (P3)
**Goal**: category 칩으로 분류별 필터.
**Independent Test**: 다른 category 스니펫 있을 때 칩 선택 → 그 분류만.
### Tests (RED 먼저)
- [x] T032 [P] [US3] `core/search` category 필터 케이스 추가 (`core/search.test.ts`)
### Impl
- [x] T033 [US3] category 필터 확정(`core/search.ts` 파라미터 활용) + 목록에서 존재하는 category 도출 (`core/search.ts`, `SnippetPalettePage.tsx`)
- [x] T034 [US3] `CategoryChips` — "전체" + categories, `Ctrl+←/→` 칩 이동, 페이지 배선 (`2_frontend/src/features/snippets/components/CategoryChips.tsx`, `SnippetPalettePage.tsx`)
**Checkpoint**: 전 story 동작. quickstart 9.
---
## Phase 6: Polish & 검증
- [ ] T035 `npm run format``npm run lint` + `npm run test` + `npm run build` 통과 + `dotnet test 3_windowsApp/CodeAssist.Tests` 통과
- [ ] T036 quickstart.md 수동 시나리오 1~11 전체 확인(붙여넣기 대상 정확성·영속·시드)
- [ ] T037 [P] `specs/001-snippet-palette/report.md`(계획 vs 결과) 작성 → 최종 승인용
---
## Dependencies & Execution Order
- **Phase 1 Setup** → **Phase 2 Foundational**(전 story 차단) → **US1 → US2 → US3****Polish**.
- Foundational 안: T004→T005→T006 순(같은 C# App/WebHostView 계열). T007/T008/T009/T010은 서로 [P]. T011은 T002 이후.
- US 간: US1은 read/paste, US2는 write(같은 repo/api/page 파일 확장이라 US1 뒤가 안전 — 병렬 시 파일 충돌 주의), US3은 category(대부분 독립).
- Story 내: 테스트(RED) → 순수함수/모델 → repo/bridge → api/hooks → 페이지/컴포넌트.
## Parallel Opportunities
- Setup: T002·T003 [P].
- Foundational: T007·T008·T009·T010 [P](C# 저장소 vs 프론트 브릿지 서로 다른 파일).
- US1 테스트: T012·T013·T014·T015 [P]. US1 순수함수: T016·T017 [P]. 컴포넌트 T023 [P].
- **주의**: `SnippetRepository.cs`·`snippets.api.ts`·`SnippetPalettePage.tsx`는 여러 태스크가 손대므로 같은 story 안에선 [P] 안 붙임(파일 충돌).
## Implementation Strategy
1. Setup + Foundational → "Ctrl+1로 /snippet 뜨고 데이터 왕복" 체크포인트.
2. **US1(MVP)** → 독립 검증(붙여넣기 되면 도구로서 완성) → 데모.
3. US2(CRUD) → US3(category) 순 증분.
4. 각 단계 끝 `stage-N.md`, 완료 전 `superpowers:verification-before-completion` + `/code-review`, 최종 `report.md` → 승인 → merge(`/cmp`).
## Notes
- [P] = 다른 파일·무의존. [US#] = 추적용.
- 정당 편차(plan §Constitution): 데이터는 `@/lib/api/client` 아닌 브릿지, `Snippet` 타입은 feature-local.
- 붙여넣기·핫키·앞창캡처·팔레트 nav는 **기존 재사용** — 새로 만들지 말 것.
## 2026-09-11: 단계별 런처와 실제 창 크기
`spec.md`의 2026-09-11 사용자 승인 범위만 완료함. 위 초기 .NET 이식·CRUD·사용기록의 미완료 항목을 완료로 바꾸지 않음.
- [x] 검색창만 시작하고 검색 후 결과를 표시. 미선택 Enter는 아무것도 복사하지 않음.
- [x] 화살표·클릭으로 직접 선택할 때만 프리뷰를 열고 검색·분류 변경 시 선택 해제.
- [x] 결과 행을 간결하게 바꾸고 하단 키 안내 추가. 기존 코드뷰어·분할 크기 조절 유지.
- [x] Tauri `window_snippet_layout`으로 검색 640×84 / 결과 640×440 / 프리뷰·편집 960×600 적용. DPI·작업 영역·창 테두리 보정.
- [x] 챗봇 복귀·최대화 복원·종료 저장에서 원래 배치 보존. CloseRequested 캐시 순서 문제를 실제로 재현하고 명시적 저장으로 수정.
- [x] Esc 포커스 복원과 재소환 시 삭제 확인창 닫힘을 실패하는 회귀 검사로 재현 후 수정.
- [x] `npm run format`, lint(오류 0·기존 경고 9), 프론트 46파일·259검사, build 통과. Rust fmt·6검사 통과·로컬 DB 전용 1검사 기본 제외, clippy `-D warnings` 통과.
- [x] 실제 저장된 스니펫 SELECT 검색 7개, 키보드 선택·코드 프리뷰·빈 결과·편집·재소환을 Tauri에서 확인. 데이터 저장·삭제는 실행하지 않음.
- [x] 실제 Windows 크기 전환·화면 우하단 확장·최대화 전 일반 배치 복원·축소 종료 후 960×680 재시작 확인.
## 2026-09-11: 코드 프리뷰 군더더기 제거
- [x] 프리뷰 이름·설명·분류, 장식 점과 코드 헤더, 박스 테두리·그림자를 제거. 문법 강조와 줄 번호 유지.
- [x] `CodeBlock`의 plain 표시를 재사용해 프리뷰 안쪽 스크롤을 하나로 합침. 챗봇 카드의 기본 모양은 유지.
- [x] 기존 복사·붙여넣기 버튼을 `CodeActions`로 공유하고 스니펫 하단에 배치. 버튼 Enter를 전역 즉시 복사가 가로채지 않도록 수정.
- [x] 실제 Tauri 프리뷰의 헤더·버튼 0개, 세로 스크롤 1개, 하단 액션 2개와 문법 강조를 확인. 클릭·키보드 복사 원문과 창 유지 회귀 검사 통과.
- [x] format·lint(오류 0, 기존 경고 9)·46파일 260검사·build 통과.
### 별도 관찰
- [ ] 최소화된 앱을 검사 도중 종료한 뒤 `/snap`이 640×84로 시작하는 현상을 관찰함. 최소화 종료 경로의 배치 저장은 별도로 재현·확인할 것. 이번 프리뷰 UI 변경에는 Rust 수정을 포함하지 않음.
## 2026-09-11: 프리뷰 전체 창 축소
- [x] 사용자 추가 요청에 따라 코드 프리뷰만 960×600에서 840×520으로 축소. 검색 640×84·결과 640×440·편집 960×600은 유지.
- [x] 실제 Tauri에서 840×520 화면, 하단 액션 표시, 세로 스크롤 1개를 확인. F2 편집 960×600 → 닫기 후 프리뷰 840×520 복귀 확인.
- [x] Rust fmt 검사와 6개 검사 통과. 프론트는 직전 UI 변경 검증 46파일·260개 검사 및 build 통과 상태를 유지함.
## 2026-09-11: Ctrl+Enter 앱에 붙여넣기
- [x] 기존 `pasteToApp`을 Ctrl+Enter에서도 사용. 일반 Enter·복사 버튼 동작은 유지하고, 미선택·IME·편집·브라우저·반복키·추가 modifier는 차단.
- [x] 네이티브 붙여넣기를 비동기 작업으로 실행. 키 해제는 최대 2초 대기하고, 중복 요청·대상 HWND/PID/thread 변경·포커스 이탈·재입력은 취소.
- [x] `SetForegroundWindow` 직후 정상 활성화를 실패로 오판하는 경합을 실제로 재현. 80ms 구간에서 첫 활성화 전 팔레트·NULL만 기다리고, 대상 활성화 이후 이탈은 취소하도록 수정.
- [x] `SendInput` 전송 개수를 확인하고 성공 때만 창 숨김. 실패하면 창과 오류 안내 유지, 이미 복사된 원문은 클립보드에 보존.
- [x] 저장하지 않는 별도 Windows 입력창에서 실제 Ctrl+Enter 누름·반복·해제 → 1,420자 원문 1회 입력·창 숨김 확인. 기존 붙여넣기 버튼도 같은 원문 입력 확인.
- [x] 실제 네이티브 입력으로 한글·CRLF·탭·앞뒤 공백 보존 확인. 대상 없음·닫힌 대상·중복 요청·2초 키 대기 초과·대기 중 포커스 이탈에서 입력 취소·오류·클립보드 보존 확인.
- [x] 프론트 format·lint(오류 0, 기존 경고 9)·46파일 264검사·build 통과. Rust fmt·6검사(로컬 DB 검사 1개 기본 제외)·clippy `-D warnings` 통과.
- 검증 범위: 별도 WinForms 입력창을 사용했고 사용자 문서·원본 스니펫 DB를 바꾸지 않음. VS Code·SAP·관리자 권한 앱 각각의 실제 수신 검사는 별도이며, 키 전송 성공이 모든 앱의 붙여넣기 수신을 보장하지는 않음.
@@ -0,0 +1,36 @@
# Specification Quality Checklist: 화면 캡쳐 → 챗 첨부
**Purpose**: 계획(plan) 넘어가기 전 spec 완성도·품질 확인
**Created**: 2026-08-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [~] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [~] No implementation details leak into specification
## Notes
- **"No implementation details" (~)**: 스니펫 기능과 동일 — "기존 아키텍처(WebView2 브릿지·멀티핫키·Composer)에 얹는 캡쳐 파이프라인"이 사용자가 못박은 전제라 아키텍처 참조(핫키명·오버레이·브릿지)가 의도적으로 들어감. 다만 구현 앵커는 **Assumptions**에 몰아뒀고, FR·SC는 사용자 관점 동작/결과로 기술.
- 나머지 전부 통과. NEEDS CLARIFICATION 없음(브레인스토밍서 A/B 결정 다 확정). `/speckit-clarify` 건너뛰고 `/speckit-plan` 가도 됨.
- plan 단계에서 `2_frontend/docs-lib/` 라이브러리 문서 선참조 규칙 적용. base64 이미지 전달 크기 천장·고DPI 보정은 plan/research에서 결정.
@@ -0,0 +1,37 @@
# Contract: 캡쳐 이미지 브릿지 + 챗 첨부
## C#→JS 메시지 (신규)
기존 `SendToWeb`(`CoreWebView2.PostWebMessageAsJson`) 채널 재사용.
| 방향 | 메시지 | 처리 |
|---|---|---|
| C#→JS | `{type:"capture.image", dataUrl}` | 프론트가 캡쳐 이미지를 챗 입력창 첨부로 받음 |
- `dataUrl`: `data:image/png;base64,...` (PNG).
- App 캡쳐 완료 순서: `ShowPalette()``NavigateApp("/snap/new")``SendCaptureImage(dataUrl)`. (navigate·소환은 기존 배선.)
- 기존 메시지(navigate·paste.target·window.*·snippets.* 등)와 같은 리스너 허브에서 `type`으로 분기.
## C# 표면 (신규/확장)
- `Shell/Platform/ScreenCapture.cs``static byte[] CapturePng(int x, int y, int width, int height)`: 물리 좌표 영역을 `CopyFromScreen`으로 PNG 바이트. (좌표 변환은 호출측=오버레이 몫.)
- `Views/CaptureOverlayWindow` — 드래그 선택 + DIP→물리 변환 + `ScreenCapture.CapturePng` 호출 → `event Action<string> Captured`(base64 dataUrl) / `event Action Cancelled`.
- `App.xaml.cs` — 핫키 id `0x1002`(Ctrl+Shift+9) → 오버레이 표시. `Captured` → 위 순서 실행. `Cancelled` → 무시.
- `PaletteWindow.SendCaptureImage(string dataUrl) => Web.SendToWeb(new { type = "capture.image", dataUrl });`
## 프론트 계약 (`bridgeNavigate.ts`)
```ts
// 마지막 캡쳐 이미지 dataUrl 을 1회 소비(마운트 레이스 대비). 없으면 "".
export function consumePendingCaptureImage(): string
// capture.image 수신 → pendingCaptureImage 갱신 + window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: { dataUrl } }))
```
## 프론트 계약 (`Composer.tsx`)
- 상태 `imageAttachments: string[]`(dataUrl). 마운트 시 `consumePendingCaptureImage()` push + `bridge:captureImage` 구독 push. X 제거. (data-model.md 규칙.)
- v1 전송: 텍스트만. 이미지 첨부는 미전송 + 전송 후 정리.
## 순수 브라우저(dev)
`SendToWeb`/`chrome.webview` 없음 → capture.image 안 옴, 캡쳐 기능 비활성(데스크톱 전용). 기존 챗은 그대로.
+39
View File
@@ -0,0 +1,39 @@
# Data Model: 화면 캡쳐 → 챗 첨부 (Phase 1)
캡쳐 이미지는 저장소 없이 메모리→data URL로만 흐른다. v1 영속 없음.
## 엔티티
### 캡쳐 이미지 (Capture Image)
선택 영역의 화면 스냅샷 한 장.
| 표현 | 형태 | 설명 |
|---|---|---|
| C# 내부 | `Bitmap` → PNG `byte[]` | `CopyFromScreen` 결과 |
| 전달 | `dataUrl: string` | `data:image/png;base64,...` |
| 프론트 첨부 | `string`(dataUrl) | Composer `imageAttachments` 배열의 한 항목 |
- v1엔 파일/DB 영속 없음. 챗 첨부 상태로만 존재.
## 프론트 상태
### Composer 이미지 첨부 (`Composer.tsx`)
- 신규 상태: `imageAttachments: string[]` — 각 항목은 PNG data URL. **텍스트 첨부(`attachments`)와 별개.**
- 렌더: 썸네일 `<img>` + 제거 `X` 버튼(칩). 텍스트 첨부 칩 옆/위.
- 추가 규칙:
- 마운트 시 `consumePendingCaptureImage()` 값 있으면 push(누락 방지).
- `bridge:captureImage` 이벤트 수신 시 push(누적 — 여러 번 캡쳐 = 여러 첨부, FR-008).
- X → 해당 항목 제거(FR-007).
- **전송 시(v1)**: 텍스트만 전송(기존 그대로). 이미지 첨부는 **전송하지 않고**, 전송 후 `imageAttachments`는 비운다(텍스트 첨부와 동일하게 정리 — 안 쓰지만 쌓이지 않게). ← FR-009. 백엔드 붙으면 이 지점에서 payload에 실어 보내도록 확장.
## 브릿지 모듈 상태 (`bridgeNavigate.ts`)
- `pendingCaptureImage: string`("" = 없음) — 마지막 수신 캡쳐 dataUrl(마운트 레이스 대비).
- `consumePendingCaptureImage(): string` — 값 반환 후 `""`로 비움(read-once).
- `capture.image` 수신 시: `pendingCaptureImage = dataUrl` + `bridge:captureImage`(detail: `{dataUrl}`) 발생.
- (참고) pasteTarget은 "지속 표시"라 get, 캡쳐는 "1회 소비"라 consume — 세만틱만 다름.
## 상태 전이
캡쳐 없음 → (핫키·드래그·확정) → dataUrl 생성/전달 → Composer 이미지 첨부 1개 추가 → (X 제거 | 전송 시 정리) → 없음.
취소(Esc·무효선택) → 아무 상태 변화 없음.
+83
View File
@@ -0,0 +1,83 @@
# Implementation Plan: 화면 캡쳐 → 챗 첨부 (Screen Capture to Chat)
**Branch**: `002-capture-to-chat` | **Date**: 2026-08-06 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/002-capture-to-chat/spec.md`
## Summary
전역 핫키 `Ctrl+Shift+9` → 전체화면 투명 오버레이에서 사각형을 드래그해 그 영역을 캡쳐 → 이미지를 챗(snap) 입력창에 썸네일 첨부로 붙인다. **v1은 캡쳐 파이프라인만** — 비전 분석·웹서치·이미지 백엔드 전송은 다음 phase.
**기술 접근(재사용 우선):** 실질 신규는 **C# 캡쳐 오버레이 창 + 화면 픽셀 캡쳐**와 **Composer 이미지 첨부 UI** 둘뿐. 핫키는 이미 멀티핫키(`HotKeyService`)라 등록만 추가(id `0x1002`). 이미지 전달은 기존 C#→JS `SendToWeb`(`PostWebMessageAsJson`) 채널에 `capture.image` 메시지 한 줄. 프론트 수신은 `bridgeNavigate` 허브 + 마운트 레이스 대비 모듈 보관+이벤트(pasteTarget 패턴 그대로). 팔레트 소환·`/snap/new` navigate도 기존 배선.
## Technical Context
**Language/Version**: TypeScript 5 / React 18 (Vite) · C# / .NET 8 (WPF, WebView2)
**Primary Dependencies**: (프론트) 기존 — 신규 없음. (C#) **`System.Drawing.Common`(신규)** — `Graphics.CopyFromScreen`으로 화면 캡쳐. Microsoft.Web.WebView2(기존).
**Storage**: 없음(캡쳐 이미지는 메모리 → data URL, 챗 첨부 상태로만 존재. v1 영속 없음).
**Testing**: vitest(프론트: Composer 이미지 첨부 상태·capture.image 수신 로직) · xUnit(C#: 화면 캡쳐 좌표/DPI 변환 순수부분). 오버레이·CopyFromScreen 실물은 헤드리스 검증 불가 → 수동.
**Target Platform**: Windows 데스크톱(WebView2 런처). 순수 브라우저(dev)엔 캡쳐 없음(데스크톱 전용).
**Project Type**: 기존 웹앱(2_frontend) + 데스크톱 호스트(3_windowsApp)에 기능 추가.
**Performance Goals**: 핫키→오버레이 즉시. 드래그 종료→챗 첨부까지 체감 몇 초 내.
**Constraints**: 멀티모니터 커버(가상화면 전체). 캡쳐 좌표는 물리 픽셀(오버레이 DIP↔픽셀 배율 변환 필요).
**Scale/Scope**: 개인 단일 PC. 화면 캡쳐 1장/회, 첨부 누적 소수.
## Constitution Check
*GATE: Phase 0 전 통과, Phase 1 후 재확인.*
`.specify/memory/constitution.md`는 빈 템플릿 → 형식 게이트 없음. **실질 캐논 = `CLAUDE.md`**(재사용 우선·feature 컨벤션·docs-lib 선참조·TDD).
-**재사용 우선**: 멀티핫키·SendToWeb 브릿지·팔레트 소환·navigate·bridge 이벤트 패턴·Composer 첨부 UI 재사용. 실질 신규는 캡쳐 오버레이+화면캡쳐, Composer 이미지 첨부.
-**feature 위치**: 캡쳐는 챗(snap) 입력 경로라 프론트 신규는 `features/snap`(Composer) + `lib/bridge`. C#는 `CodeAssist.App/Views`(오버레이) + `CodeAssist.Shell/Platform`(캡쳐 헬퍼).
- ⚠️ **의도된 편차 — 이미지가 챗 메시지로 전송되지 않음(v1)**: 첨부는 표시·제거만, 전송은 기존 텍스트 그대로. 백엔드 멀티모달 계약 나올 때 전송 payload와 함께 배선. spec Out of Scope에 명시.
Gate: **PASS**.
## Project Structure
### Documentation (this feature)
```text
specs/002-capture-to-chat/
├── plan.md # 이 파일
├── research.md # Phase 0 — 캡쳐 API·DPI·전달·수신·오버레이 결정
├── data-model.md # Phase 1 — 캡쳐 이미지·첨부 상태 + 메시지 shape
├── contracts/
│ └── bridge-capture.md # capture.image 메시지 + 프론트 첨부 계약
├── quickstart.md # Phase 1 — 수동 검증 시나리오
└── checklists/requirements.md
```
### Source Code (repository root)
```text
3_windowsApp/
├── CodeAssist.Shell/
│ ├── Platform/ScreenCapture.cs # ★ 신규: 물리좌표 영역 → PNG 바이트(CopyFromScreen)
│ └── CodeAssist.Shell.csproj # + System.Drawing.Common
├── CodeAssist.App/
│ ├── Views/CaptureOverlayWindow.xaml(.cs) # ★ 신규: 전체화면 투명 오버레이·드래그 선택·Esc 취소
│ ├── App.xaml.cs # 확장: 3번째 핫키(Ctrl+Shift+9) + 캡쳐 코디네이션
│ └── Views/PaletteWindow.xaml.cs # 확장: SendCaptureImage(dataUrl) 패스스루
2_frontend/src/
├── lib/bridge/
│ └── bridgeNavigate.ts # 확장: capture.image 수신 → 보관+이벤트(consume 세만틱)
└── features/snap/components/
└── Composer.tsx # 확장: 이미지 첨부(썸네일+X) + 마운트 시 캡쳐 이미지 수령
```
**Structure Decision**: 기존 2폴더 구조 유지. C# 신규 2(오버레이·캡쳐헬퍼) + 확장 2(App·PaletteWindow). 프론트 확장 2(브릿지·Composer). 새 라우트/화면 없음(기존 `/snap/new` 재사용).
## Complexity Tracking
> Constitution 위반 없음. 편차 1건(v1 이미지 미전송)은 spec Out of Scope에 정당화됨. 별도 표 불필요.
+34
View File
@@ -0,0 +1,34 @@
# Quickstart: 화면 캡쳐 → 챗 첨부 검증
end-to-end "돌아간다" 확인 가이드. 구현은 tasks.md/구현 단계.
## 전제
- Windows + WebView2. `3_windowsApp` 빌드(.NET SDK), `2_frontend` `npm install`.
- DEBUG 실행은 WebView2 안에 vite dev 물어 띄움 → 브릿지 살아있음.
## 실행
```pwsh
# 데스크톱 런처(DEBUG) — CodeAssist.App 빌드/실행
```
## 단위 검증 (호스트 없이)
```pwsh
cd 2_frontend; npm run test # Composer 이미지 첨부 상태·capture.image 수신(consume) 로직
dotnet test 3_windowsApp/CodeAssist.Tests # (있으면) 좌표/DPI 변환 순수부
```
## end-to-end 시나리오 (수동 — 오버레이·CopyFromScreen은 헤드리스 불가)
1. **오버레이** (AS-1): 아무 창 포커스 → `Ctrl+Shift+9` → 모든 모니터가 어두워지며 캡쳐 오버레이 뜸(십자 커서).
2. **캡쳐→첨부** (AS-2): 영역 드래그 후 놓음 → 오버레이 닫히고 앱 창이 떠 **새 대화**로 이동, 입력창에 그 영역 **썸네일 첨부** 붙음.
3. **내용 일치** (SC-002): 첨부 썸네일이 드래그로 고른 영역과 내용·경계 일치.
4. **취소** (AS-3): 오버레이서 `Esc` 또는 드래그 없이 클릭 → 아무 캡쳐 없이 닫힘, 챗·클립보드 변화 없음.
5. **멀티모니터** (AS-5/SC-003): 보조 모니터 영역 드래그 → 그 영역 정확히 캡쳐.
6. **제거** (AS-4): 첨부의 X → 그 첨부 사라짐.
7. **누적** (AS-6): 한 번 더 캡쳐 → 첨부 하나 더 추가(기존 유지).
8. **v1 전송 경계** (FR-009): 이미지 첨부 있는 상태로 메시지 전송 → **텍스트만** 나가고 이미지는 백엔드로 안 감(분석 안 일어남). (백엔드 붙기 전까지 정상.)
## 참조
- 메시지·표면 계약: [contracts/bridge-capture.md](./contracts/bridge-capture.md)
- 데이터/상태: [data-model.md](./data-model.md)
- 결정 근거(캡쳐 API·DPI·전달): [research.md](./research.md)
+50
View File
@@ -0,0 +1,50 @@
# 완료 보고: 화면 캡쳐 → 챗 첨부 (002-capture-to-chat)
**브랜치**: `feat/capture-to-chat` · **작성**: 2026-08-06 · **상태**: 최종 승인 대기
전역 핫키 `Ctrl+Shift+9` → 전체화면 오버레이 드래그 → 그 영역 캡쳐 → 챗 입력창에 이미지 첨부(썸네일+X). **v1 = 캡쳐 파이프라인만**, 멀티모달 분석·웹서치·이미지 백엔드 전송은 다음 phase.
## 계획 vs 결과
| 항목 | 계획 | 결과 |
|---|---|---|
| Setup (T001-T002) | System.Drawing.Common, PerMonitorV2 manifest | ✅ |
| C# 캡쳐 (T004-T008) | 좌표변환 순수함수+CopyFromScreen, 오버레이 창, 3번째 핫키, App 코디, SendCaptureImage | ✅ 좌표변환 xUnit 5개(경계 포함), dotnet test 22/22 |
| 프론트 (T003,T009,T010) | capture.image 수신(consume), Composer 이미지 첨부 | ✅ bridgeNavigate/Composer 테스트, 프론트 237/237 |
| Polish | 검증·quickstart·report | 검증 ✅ / quickstart 수동(사용자) / 이 문서 |
## 신규 vs 재사용
**신규(실질):** C# `CaptureOverlayWindow`(전체화면 투명·드래그선택·Esc/무효 취소) + `ScreenCapture`(좌표변환 순수부 + CopyFromScreen→PNG) + Composer 이미지 첨부 UI.
**재사용:** 멀티핫키(3번째 등록만), `SendToWeb` 브릿지(`capture.image` + 콜드스타트 버퍼 패턴), 팔레트 소환·`/snap/new` navigate, bridgeNavigate 이벤트 허브, Composer 첨부칩 패턴.
## 리뷰에서 잡아 고친 것
- **[C# Imp1]** 캡쳐 실패 예외 미처리 → 앱 크래시 가능. → `pw<=0||ph<=0` 가드 + try/catch로 취소 흡수.
- **[C# Imp2]** 콜드스타트 시 `capture.image` 유실(SendToWeb가 navigate만 버퍼). → `capture.image`도 버퍼링+NavigationCompleted flush.
- **[C# Min1]** Esc/포커스아웃 경로 마우스캡처 미해제. → `Finish()`로 이동.
- **[프론트 Critical]** read-once consume 구멍 — 이벤트 경로가 pending 안 비워 리마운트 시 stale 재첨부. → `onCapture``consumePendingCaptureImage()` 호출로 양쪽 경로 비움 + 회귀 테스트.
## 검증
- 프론트 **237/237**, C# **22/22**, 빌드 클린(앱 실행 중이면 App exe copy-lock은 컴파일 이슈 아님).
- **런타임 E2E(오버레이·CopyFromScreen·멀티모니터·DPI)는 헤드리스 불가 → 사용자 수동**(quickstart 1~8).
## 남은 것 / 천장(추후)
- **[수동확인]** quickstart — 실제 데스크톱에서 캡쳐·첨부·취소·멀티모니터.
- **[다음 phase]** 이미지를 실은 메시지를 멀티모달 백엔드로 전송·비전 분석·응답, 웹서치. 전송 payload 배선도 그때.
- **[천장]** mixed-DPI 좌표 보정, 대용량 base64 임시파일/blob 최적화.
## 최종 리뷰 (whole-branch, opus)
리소스(Bitmap/Graphics using·오버레이 누수 없음·예외 흡수·마우스캡처)·보안(로컬 캡쳐→자기앱, data:URI 스크립트 경로 없음)·회귀(3번째 핫키 독립·버퍼 2개 무간섭·텍스트첨부/전송 무손상) 다 클린.
신규 Important 1건 잡아 **fix wave로 처리 완료**(재리뷰 통과):
- **기존 대화방에서 캡쳐 시 이미지 유실**(내 read-once fix가 연 반대 구멍 — 출발지 Composer가 pending 훔침). → **`acceptCapture` prop 게이팅**(NewChatPage만 캡쳐 수신) + 회귀 테스트.
**parked (cosmetic, merge 무관):** 캡쳐 직후 붙여넣기-대상 배지가 잠깐 오버레이 자기 창으로 표기됨. 기능 무해(캡쳐는 새 대화行이라 paste-target 안 씀, 다음 소환 때 정상 갱신). 거슬리면 `ForegroundWindow.Capture`에 자기프로세스 HWND 무시 한 줄로 후속 가능.
**최종 검증:** 프론트 **238/238**, C# **22/22**, 빌드 클린.
**결론:** 문서화된 천장(멀티모달 미전송·mixed-DPI·대용량 base64) + parked cosmetic 1건 제외하고 **merge 준비 완료.** 단 오버레이·캡쳐·멀티모니터 런타임은 헤드리스 미검 → 사용자 수동 확인 권장.
+65
View File
@@ -0,0 +1,65 @@
# Research: 화면 캡쳐 → 챗 첨부 (Phase 0)
브레인스토밍서 큰 결정(챗에 얹기·자체 오버레이·v1 캡쳐만·핫키 Ctrl+Shift+9·순수 첨부까지)은 끝. 여기선 **구현 미해결점**만.
---
## R1. 화면 캡쳐 API
**결정**: **`System.Drawing.Common`** 패키지 + `Graphics.CopyFromScreen(srcX, srcY, 0, 0, size)``Bitmap` → PNG(`MemoryStream`) → base64.
**Rationale**: 스크린샷 고전 방식, 코드 최소. WPF 프로젝트라 기본 참조엔 없어 패키지 추가(Windows 전용 API지만 이 앱은 Windows 데스크톱). `CodeAssist.Shell.csproj`에 추가.
**Alternatives 기각**: GDI P/Invoke(BitBlt+GetDC/BitmapSource) — 패키지 없이 되지만 코드 많고 손 많이 감. Windows.Graphics.Capture(WinRT) — 최신이나 interop 복잡, YAGNI.
## R2. 좌표·DPI 변환
**결정**: 오버레이는 WPF DIP 좌표로 그리고, **선택 사각형(DIP) → 물리 픽셀**로 변환해 `CopyFromScreen`에 넘긴다.
- 오버레이 창은 가상화면(`SystemParameters.VirtualScreen*`, DIP) 전체를 덮음.
- 변환: 오버레이의 DPI 스케일(`VisualTreeHelper.GetDpi(this)` 또는 `PresentationSource``CompositionTarget.TransformToDevice`)로 (오버레이 원점 + 선택 DIP) → 물리 px.
- 앱을 **PerMonitorV2 DPI 인지**로(app.manifest) 두어 스케일 창서도 좌표가 맞게.
**ponytail 천장**: 서로 배율 다른 멀티모니터(mixed-DPI)에선 가상화면 한 DPI로 계산하면 어긋날 수 있음 → v1은 단일/동일배율 케이스 맞추고, mixed-DPI 보정은 추후. `ponytail:` 주석으로 박음.
## R3. 오버레이 창
**결정**: 신규 `CaptureOverlayWindow`(WPF Window).
- `WindowStyle.None` + `AllowsTransparency=true` + 반투명 어두운 배경(예: `#55000000`) + `Topmost=true` + `ShowInTaskbar=false`.
- 위치·크기 = `VirtualScreen`(DIP) 전체(멀티모니터 커버). `WindowStartupLocation=Manual`.
- 위에 `Canvas` + 선택 `Rectangle`. MouseDown(시작점)→MouseMove(사각형 갱신)→MouseUp(확정). `Cursor=Cross`.
- **취소**: `Esc` KeyDown, 또는 선택 크기 임계(예: <4px) 미만이면 캡쳐 없이 닫음. 포커스 잃어도(Deactivated) 닫아 안전.
- 캡쳐 완료 시 `Captured(string dataUrl)` 이벤트, 취소 시 `Cancelled` — App이 구독. 처리 후 창 Close.
**Rationale**: 스니핑툴류 표준. 팔레트 창과 별개의 짧은 수명 창.
## R4. 이미지 전달(C#→JS)
**결정**: 기존 `SendToWeb`(`PostWebMessageAsJson`) 채널로 `{type:"capture.image", dataUrl}`(PNG base64 data URL). `PaletteWindow.SendCaptureImage(dataUrl)` 패스스루 추가.
**ponytail 천장**: 대영역 캡쳐면 base64가 수 MB → PostWebMessageAsJson 부담. v1은 그대로, 추후 임시파일(virtual host mapping) 또는 blob 전달로 최적화.
## R5. 프론트 수신(마운트 레이스)
**결정**: `bridgeNavigate` 허브(C#→JS 메시지 단일 리스너)에 `capture.image` 분기 추가.
- 모듈에 `pendingCaptureImage` 보관 + `bridge:captureImage` CustomEvent 발생.
- **read-once(consume) 세만틱**: `consumePendingCaptureImage()`가 값 반환 후 비움. Composer가 **마운트 시 consume**(네비 직후 이벤트를 놓쳐도 반영) + **이벤트 구독**(이미 마운트된 경우). 둘 다 consume/clear 하므로 같은 이미지 이중 첨부 안 됨.
**Rationale**: pasteTarget/bridgeNavigate에서 검증된 "모듈 보관 + 이벤트" 레이스 패턴 재사용. 단 캡쳐는 표시 지속이 아니라 1회 소비라 consume 세만틱.
## R6. 챗 첨부·도착 흐름
**결정**:
- App 캡쳐 완료 → `ShowPalette()`(앞창 캡처+표시) + `NavigateApp("/snap/new")` + `SendCaptureImage(dataUrl)`.
- Composer에 **이미지 첨부 상태**(`imageAttachments: string[]` data URL) 신규 — 텍스트 첨부 칩과 별개. 썸네일 `<img>` + X(제거). 마운트 시 pending 캡쳐 이미지 consume해 첨부, 이후 이벤트로 추가(누적).
- **v1 전송 동작**: 메시지 전송 시 이미지 첨부는 **전송하지 않음**(FR-009). 텍스트만 기존대로. 전송 후 이미지 첨부 처리는 data-model에서 확정(§전송 시).
**Rationale**: 도착지 새 대화(`/snap/new`)는 spec Assumption. Composer는 이미 텍스트 첨부 칩 패턴이 있어 이미지 첨부도 같은 자리에.
## R7. 캡쳐 소유·조립
**결정**: 드래그·DPI변환·`CopyFromScreen``CaptureOverlayWindow`(선택 rect·DPI를 아는 곳)에서. 순수 픽셀 캡쳐(물리 x,y,w,h → PNG 바이트)는 `Shell/Platform/ScreenCapture.cs`로 분리(오버레이는 UI, 캡쳐는 Win32 — 관심사 분리, 좌표변환 순수부는 단위테스트 여지).
## 구현 시점 확인
- `System.Drawing.Common` 버전 핀(최신 안정).
- app.manifest에 PerMonitorV2 dpiAwareness 설정 여부 확인(이미 있으면 재사용).
+103
View File
@@ -0,0 +1,103 @@
# Feature Specification: 화면 캡쳐 → 챗 첨부 (Screen Capture to Chat)
**Feature Branch**: `002-capture-to-chat`
**Created**: 2026-08-06
**Status**: Draft
**Input**: 화면 영역을 사각형으로 캡쳐해서 챗(snap) 입력창에 이미지 첨부로 넣는 기능. **v1은 캡쳐 파이프라인만** — 멀티모달 비전 분석·웹서치는 백엔드 붙는 다음 phase.
---
## 개요
작업 중 화면 어디든 전역 핫키로 사각형을 그려 그 영역을 캡쳐하면, 그 이미지가 곧바로 챗 입력창에 첨부로 붙는다. 나중에 "이미지 놓고 AI랑 얘기"하려는 건데, **이번 v1은 캡쳐해서 챗에 붙이는 데까지**만 한다. 실제 분석(비전 LLM)·웹서치는 백엔드가 준비되면 그 위에 얹는다.
**한 줄 흐름**: 핫키 → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 챗 새 대화에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑).
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - 화면 캡쳐해서 챗에 첨부 (Priority: P1)
에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키(`Ctrl+Shift+9`)를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. 원하는 영역을 마우스로 드래그해 사각형으로 잡고 놓으면, 앱 창이 떠서 새 대화 화면으로 가고 그 캡쳐 이미지가 입력창에 첨부(썸네일)로 붙어 있다. 이제 질문을 타이핑하면 된다.
**Why this priority**: 이게 이 기능의 전부(v1). 캡쳐→첨부가 매끄럽게 되면 나중에 백엔드만 붙이면 "이미지로 AI와 대화"가 완성됨.
**Independent Test**: 핫키 → 오버레이 → 영역 드래그 → 놓기 → 챗 입력창에 그 영역 이미지가 첨부로 보이는지. 백엔드 없이 단독 검증 가능(전송·분석은 v1 밖).
**Acceptance Scenarios**:
1. **Given** 아무 창이 떠있음, **When** `Ctrl+Shift+9`, **Then** 모든 모니터를 덮는 어두운 캡쳐 오버레이가 뜬다.
2. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이가 닫히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다.
3. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이만 닫힌다(부작용 없음).
4. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then** 그 첨부가 제거된다.
5. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then** 그 모니터의 해당 영역이 정확히 캡쳐된다.
6. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지).
---
### Edge Cases
- **드래그 없이 클릭만 / 너무 작은 영역**: 캡쳐 취소로 간주(오버레이만 닫힘).
- **`Esc`**: 언제든 캡쳐 취소.
- **앱 창이 숨겨진 상태에서 캡쳐**: 오버레이는 별개라 동작하고, 캡쳐 후 앱 창을 소환한다.
- **아주 큰 영역**: 이미지 용량이 커질 수 있음(전달 방식 최적화는 추후 — Assumptions 천장 참고).
- **고DPI/모니터별 배율 다름**: 좌표·크기 배율 보정이 필요할 수 있음(추후 — 천장 참고).
- **첨부를 다 지우거나 캡쳐를 취소해도** 챗은 그대로 쓸 수 있어야 한다.
---
## Requirements *(mandatory)*
### Functional Requirements
**캡쳐 트리거·오버레이**
- **FR-001**: 전역 핫키 `Ctrl+Shift+9`로 화면 캡쳐 모드를 켤 수 있어야 한다. 기존 핫키(스니펫 `Ctrl+Shift+7`, 챗 `Ctrl+Shift+8`)와 별개다.
- **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 그 위에서 마우스 드래그로 선택 사각형을 그린다.
- **FR-003**: `Esc`, 또는 유효하지 않은 선택(드래그 없이 클릭/너무 작은 영역)이면 아무것도 캡쳐하지 않고 오버레이를 닫는다.
**캡쳐·전달**
- **FR-004**: 드래그로 확정된 영역의 화면 픽셀을 이미지로 캡쳐한다.
- **FR-005**: 캡쳐 완료 시 앱 창을 소환하고, **새 대화 화면**으로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다.
**챗 첨부(끝점)**
- **FR-006**: 전달된 이미지는 챗 입력창에 **썸네일 첨부**로 표시된다.
- **FR-007**: 각 이미지 첨부는 **X로 제거**할 수 있다.
- **FR-008**: 캡쳐를 여러 번 하면 이미지 첨부가 **누적**된다(기존 첨부 유지).
- **FR-009**: v1에서 이미지 첨부는 **표시·제거만** 한다. 메시지 전송은 기존 텍스트 전송 그대로이며, 이미지를 백엔드로 보내거나 분석하지 않는다.
### Key Entities
- **캡쳐 이미지**: 선택 영역의 화면 스냅샷 한 장. 챗 입력창에 붙는 첨부 단위(썸네일로 표시, X로 제거). v1에선 전송·분석에 쓰이지 않음(표시용).
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 핫키를 누른 뒤 한 번의 드래그로 이미지가 챗에 첨부되기까지 몇 초 안에 끝난다.
- **SC-002**: 첨부된 이미지가 사용자가 드래그로 고른 영역과 내용·경계가 일치한다.
- **SC-003**: 모니터가 여러 개여도 어느 모니터의 영역이든 캡쳐된다.
- **SC-004**: `Esc`·무효 선택 시 100% 취소되고 챗·클립보드 등에 아무 부작용이 없다.
- **SC-005**: 캡쳐~챗 첨부 표시까지 흐름이 끊기지 않고(오버레이 닫힘→앱 소환→첨부) 자연스럽게 이어진다.
---
## Assumptions
- **v1 범위 = 캡쳐 파이프라인만.** 이미지를 실은 메시지를 멀티모달 LLM 백엔드로 보내 **비전 분석·응답**하는 것, **웹서치** 툴은 다음 phase(백엔드 계약 나올 때). **전송-with-이미지 payload 배선도 v1 밖** — v1의 전송은 기존 텍스트 그대로.
- **캡쳐 도착지 = 새 대화 화면.** 캡쳐하면 새 챗을 열고 거기에 붙인다(기존 세션에 붙이는 건 v1 밖).
- **재사용**: 기존 멀티핫키 등록, C#→JS 브릿지(SendToWeb/PostWebMessageAsJson), 팔레트 창 소환·navigate, 브릿지 이벤트 패턴(pasteTarget/bridgeNavigate 식 — 마운트 레이스 대비 모듈 보관+이벤트), Composer의 첨부 칩 UI 패턴을 최대한 얹는다. 캡쳐 오버레이 창과 캡쳐 로직만 실질 신규.
- **현재 이미지 처리와의 관계**: 지금 이미지 붙여넣기는 클립보드 이력 사이드바로만 감(챗에 "보내는 첨부" 개념 없음). 이 기능이 **챗 입력창 이미지 첨부**를 처음 도입한다.
- **ponytail 천장(추후 보완)**: ① 고DPI/모니터별 배율 좌표 보정, ② 큰 영역 캡쳐 시 base64가 커지면 임시파일/blob 전달로 최적화.
## Out of Scope (v1)
- 이미지 비전 분석·AI 판단·응답(멀티모달 백엔드).
- 웹서치.
- 이미지를 실은 메시지의 백엔드 전송(payload 포맷 포함).
- 기존(진행 중) 세션에 캡쳐 붙이기, 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.
+89
View File
@@ -0,0 +1,89 @@
---
description: "Task list for 화면 캡쳐 → 챗 첨부 (Screen Capture to Chat)"
---
# Tasks: 화면 캡쳐 → 챗 첨부 (Screen Capture to Chat)
**Input**: `specs/002-capture-to-chat/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md)
**Tests**: 포함(superpowers TDD + `npm run test`/`dotnet test` 통과 = 완료 기준). 단 오버레이·`CopyFromScreen`·캡쳐 실물은 헤드리스 검증 불가 → 순수부만 단위테스트 + 나머지 수동.
**Organization**: user story 1개(P1) = 전부 = MVP. Setup 후 US1.
## Format: `[ID] [P?] [US#?] 설명 (파일경로)`
- **[P]**: 다른 파일·무의존 → 병렬. **[US1]**: user story.
- 핸드오프: 여기서부터 superpowers 뒷단(`subagent-driven-development` + `test-driven-development`). 단계 끝마다 `specs/002-capture-to-chat/stage-N.md`.
---
## Phase 1: Setup
- [ ] T001 `System.Drawing.Common` 패키지 추가 (`3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj`) — `CopyFromScreen`
- [ ] T002 [P] `PerMonitorV2` dpiAwareness 확인/추가 (`3_windowsApp/CodeAssist.App/app.manifest` — 없으면 생성·csproj `ApplicationManifest` 연결). 스케일 창서 캡쳐 좌표 정확도용
---
## Phase 2: User Story 1 - 화면 캡쳐해서 챗에 첨부 (P1) 🎯 MVP
**Goal**: `Ctrl+Shift+9` → 오버레이 드래그 → 캡쳐 → 새 대화 입력창에 이미지 첨부(썸네일+X).
**Independent Test**: 핫키→드래그→놓기 → 챗 입력창에 그 영역 썸네일 첨부가 뜨는지(백엔드 없이). Esc/무효선택 취소, 멀티모니터, 제거·누적.
### Tests (RED 먼저)
- [ ] T003 [P] [US1] `bridgeNavigate` capture.image 수신 + `consumePendingCaptureImage`(read-once) 단위테스트 — mock `chrome.webview`, `snippetBridge.test` 스타일 (`2_frontend/src/lib/bridge/bridgeNavigate.test.ts`)
- [ ] T004 [P] [US1] `ScreenCapture` 좌표 변환 순수부(선택 DIP rect + 오버레이 원점 + DPI배율 → 물리 px rect) xUnit (`3_windowsApp/CodeAssist.Tests/ScreenCaptureTests.cs`)
### Impl — C# 캡쳐
- [ ] T005 [US1] `ScreenCapture.cs``CapturePng(int x,int y,int w,int h): byte[]` (`Graphics.CopyFromScreen``Bitmap`→PNG) + 순수 좌표변환 헬퍼(T004 대상) 분리 (`3_windowsApp/CodeAssist.Shell/Platform/ScreenCapture.cs`)
- [ ] T006 [US1] `CaptureOverlayWindow.xaml(.cs)` — 전체화면(가상화면) 투명·어두운 오버레이, 십자 커서, 드래그 선택 사각형, `Esc`/무효(너무 작음·클릭만) 취소, 확정 시 DIP→물리 변환→`ScreenCapture.CapturePng`→base64 dataUrl. `event Action<string> Captured` / `event Action Cancelled`. 처리 후 Close (`3_windowsApp/CodeAssist.App/Views/CaptureOverlayWindow.xaml`, `.xaml.cs`)
- [ ] T007 [US1] `App.xaml.cs` — 3번째 핫키(id `0x1002`, `MOD_CONTROL|MOD_SHIFT|VK_9`) 등록(멀티핫키 재사용) → 눌리면 `CaptureOverlayWindow` 표시. `Captured``ShowPalette()`+`_palette.NavigateApp("/snap/new")`+`_palette.SendCaptureImage(dataUrl)`. `Cancelled`→무시. 핫키 상수 한 곳에 (`3_windowsApp/CodeAssist.App/App.xaml.cs`)
- [ ] T008 [US1] `PaletteWindow.SendCaptureImage(string dataUrl)``Web.SendToWeb(new { type = "capture.image", dataUrl })` (`3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml.cs`)
### Impl — 프론트
- [ ] T009 [US1] `bridgeNavigate.ts``capture.image` 분기: `pendingCaptureImage` 보관 + `bridge:captureImage`(detail `{dataUrl}`) dispatch + `consumePendingCaptureImage()` export(read-once) (`2_frontend/src/lib/bridge/bridgeNavigate.ts`)
- [ ] T010 [US1] `Composer.tsx``imageAttachments: string[]` 상태 + 썸네일 `<img>`+X 칩 UI(텍스트 첨부 칩 옆). 마운트 시 `consumePendingCaptureImage()` push + `bridge:captureImage` 구독 push(누적, FR-008). X 제거(FR-007). **전송 시 이미지 미전송 + 정리**(FR-009) (`2_frontend/src/features/snap/components/Composer.tsx`)
**Checkpoint**: US1 단독 동작(MVP). **STOP & VALIDATE** — quickstart 1~8.
---
## Phase 3: Polish & 검증
- [ ] T011 `npm run format``lint`+`test`+`build` 통과 + `dotnet build 3_windowsApp` + `dotnet test 3_windowsApp/CodeAssist.Tests` 통과
- [ ] T012 quickstart.md 수동 시나리오 1~8 확인(오버레이·캡쳐 내용일치·Esc/무효 취소·멀티모니터·제거·누적·v1 전송경계)
- [ ] T013 [P] `specs/002-capture-to-chat/report.md`(계획 vs 결과) 작성 → 최종 승인용
---
## Dependencies & Execution Order
- **Phase 1 Setup** → **US1****Polish**.
- US1 안: 두 갈래가 `capture.image` 계약에서 만남 —
- C# 갈래: T005(캡쳐헬퍼) → T006(오버레이, T005 씀) → T007(핫키·코디, T006 씀) → T008(전송 패스스루).
- 프론트 갈래: T009(수신) → T010(Composer, T009 씀).
- 두 갈래는 서로 독립(파일 disjoint) → C#/프론트 병렬 가능하나 **implementer는 순차 dispatch**(같은 git index — 스니펫 때 레이스 교훈).
- 테스트 T003(프론트)·T004(C#) 서로 [P].
## Parallel Opportunities
- Setup T002 [P].
- 테스트 T003·T004 [P].
- C# 갈래와 프론트 갈래는 파일 disjoint(3_windowsApp vs 2_frontend)라 논리상 병렬이지만, git index 레이스 방지 위해 implementer는 순차로.
- **주의**: `App.xaml.cs`·`bridgeNavigate.ts`·`Composer.tsx`는 각 갈래 내 순차(같은 파일 확장).
## Implementation Strategy
1. Setup(패키지·manifest).
2. **US1 = MVP** — C# 캡쳐 파이프라인 + 프론트 수신·첨부. 계약(`capture.image`) 기준 양쪽 맞춤.
3. 단계 끝 `stage-N.md`, 완료 전 `verification-before-completion` + `/code-review`, `report.md` → 승인 → `/cmp` merge.
## Notes
- [P] = 다른 파일·무의존. [US1] = 추적용.
- 정당 편차(plan §Constitution): v1 이미지 미전송(백엔드 phase).
- 재사용: 멀티핫키·SendToWeb·팔레트 소환·navigate·bridge 이벤트 패턴·Composer 첨부칩 — 새로 만들지 말 것.
- 오버레이·CopyFromScreen 실물은 수동 검증(T012). 순수부(좌표변환·수신 consume)만 단위테스트.
@@ -0,0 +1,35 @@
# Specification Quality Checklist: 토큰 수·비용 표시 (token-cost)
**Purpose**: 계획(plan) 넘어가기 전에 spec 이 충분한지 확인
**Created**: 2026-08-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] 구현 세부(언어·프레임워크·API) 안 들어감
- [x] 사용자 가치·필요 중심으로 씀
- [x] 비개발자도 읽을 수 있게 씀
- [x] 필수 섹션 다 채움
## Requirement Completeness
- [ ] [NEEDS CLARIFICATION] 마커 남아있지 않음 — **3개 남음 (FR-003·FR-011·FR-013)**
- [x] 요구사항이 검증 가능하고 애매하지 않음
- [x] 성공 기준이 측정 가능함
- [x] 성공 기준이 기술 중립적임
- [x] 인수 시나리오 다 정의됨
- [x] 엣지 케이스 식별됨
- [ ] 범위가 명확히 그어짐 — **FR-013(기간 단위 집계 포함 여부) 미결이라 상한이 안 잡힘**
- [x] 의존성·가정 정리됨
## Feature Readiness
- [x] 기능 요구마다 인수 기준 있음
- [x] 유저 스토리가 주요 흐름 다 덮음
- [ ] 성공 기준 충족 가능 여부 확인 — **비용 데이터 출처(FR-003) 확정 전까지 보류**
- [x] 구현 세부가 spec 에 새지 않음
## Notes
- 미완 항목은 아래 Q1~Q3 답 받으면 바로 해소됨
- Q1(비용 출처)·Q3(범위)는 plan 의 크기를 바꾸는 질문이라 `/speckit-plan` 전에 반드시 확정 필요
+127
View File
@@ -0,0 +1,127 @@
# Feature Specification: 토큰 수·비용 표시 (token-cost)
**Feature Branch**: `feat/token-cost` (미생성 — spec 단계)
**Created**: 2026-08-09
**Status**: Draft
**Input**: User description: "토큰 수, 토큰 비용" (`docs/orders/20260727.md` 미완료 항목)
## 배경 (현재 상태)
이미 있는 것:
- 답변(assistant) 말풍선 아래에 `총 3.2k tok · 4.1s` — 그 호출의 전체 토큰(input+output)과 소요시간
- 챗 헤더에 세션 컨텍스트 게이지 — `현재 점유 / 한도`(기본 128k), 70%↑ 주황·90%↑ 빨강
없는 것:
- **돈이 얼마 나갔는지가 어디에도 안 보임.** 메시지 계약에 비용 필드(`costUsd`)가 자리는 있는데 화면에서 한 번도 안 씀
- 입력 토큰 / 출력 토큰이 따로 안 보임 — 합계만 보임
- 대화 하나를 통틀어 **누적** 얼마 썼는지 안 보임 (헤더 게이지는 "직전 호출 점유량"이지 누적이 아님)
그래서 이 feature 의 본체는 **비용 노출**이고, 토큰 표시는 거기에 붙는 보강.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - 답변마다 얼마 썼는지 보기 (Priority: P1)
사용자가 질문을 하고 답변을 받으면, 그 답변 바로 아래에 토큰 수와 함께 **이 한 번의 답변에 든 비용**이 보인다. 답변이 길어지거나 컨텍스트가 커졌을 때 "방금 게 비쌌구나"를 그 자리에서 안다.
**Why this priority**: 비용을 아예 못 보는 게 지금의 유일한 진짜 결핍. 이거 하나만 있어도 "내가 쓰는 만큼 돈이 나간다"는 감각이 생겨서 MVP 로 성립함.
**Independent Test**: 대화 하나 보내고 답변 말풍선 아래에 비용이 찍히는지, 새로고침 후 그 대화를 다시 열어도 같은 값이 남아있는지로 단독 검증 가능.
**Acceptance Scenarios**:
1. **Given** 사용자가 챗에 질문을 보내 답변이 끝난 상태, **When** 답변 말풍선 아래를 보면, **Then** 토큰 수·비용·소요시간이 한 줄로 보인다
2. **Given** 예전에 나눈 대화, **When** 그 대화를 다시 열면, **Then** 각 답변에 그때의 비용이 그대로 보인다 (다시 계산하지 않음)
3. **Given** 비용 정보가 없는 답변(옛 데이터·서버가 값을 안 줌), **When** 그 답변을 보면, **Then** 비용 칸만 빠지고 토큰·시간은 그대로 보인다 (빈칸이나 0 을 억지로 안 보여줌)
---
### User Story 2 - 이 대화에 지금까지 얼마 썼는지 보기 (Priority: P2)
사용자가 대화창 위쪽에서 **이 대화방 전체의 누적 토큰과 누적 비용**을 본다. 길게 이어온 대화가 얼마나 비싼지 한눈에 판단하고, 새 대화로 갈아탈지 정한다.
**Why this priority**: 답변별 비용(US1)만 있으면 사용자가 머리로 더해야 함. 누적이 있어야 "이 대화 접고 새로 팔까"라는 결정을 할 수 있음. 다만 US1 없이 이것만 있으면 어디서 비쌌는지 못 짚어서 후순위.
**Independent Test**: 한 대화에서 3번 질문한 뒤 헤더의 누적값이 세 답변 비용의 합과 맞는지로 검증.
**Acceptance Scenarios**:
1. **Given** 답변이 3개 쌓인 대화, **When** 헤더를 보면, **Then** 누적 토큰과 누적 비용이 보이고, 그 값은 각 답변에 찍힌 값의 합과 같다
2. **Given** 대화 도중 새 답변이 끝남, **When** 그 순간, **Then** 누적값이 바로 늘어난다 (새로고침 필요 없음)
3. **Given** 새 대화를 시작, **When** 아직 답변이 없으면, **Then** 누적값은 0 이거나 표시되지 않는다
4. **Given** 비용을 모르는 답변이 섞인 대화, **When** 누적을 보면, **Then** 아는 것만 더한 값임을 사용자가 알 수 있다 (예: `≥` 표기 또는 안내 문구)
---
### User Story 3 - 입력/출력 나눠 보기 (Priority: P3)
사용자가 토큰 표시에 마우스를 올리면 **입력 토큰과 출력 토큰이 따로** 보인다. 비용이 컨텍스트(입력) 때문인지 긴 답변(출력) 때문인지 구분한다.
**Why this priority**: 진단용 정보라 없어도 기능은 성립. 화면을 안 어지럽히려면 평소엔 숨기고 올렸을 때만 보이는 게 맞음.
**Independent Test**: 답변의 토큰 표시에 hover 해서 입력·출력 값이 나뉘어 나오는지로 검증.
**Acceptance Scenarios**:
1. **Given** 답변에 토큰 수가 찍혀 있음, **When** 그 위에 마우스를 올리면, **Then** 입력 N / 출력 M 이 따로 보인다
2. **Given** 입력·출력 중 하나만 알려진 답변, **When** hover 하면, **Then** 아는 쪽만 보인다
---
### Edge Cases
- 답변 도중 사용자가 **중지**를 눌러 스트림이 끊긴 경우 — 그때까지의 비용을 보여줄지, 아무것도 안 보여줄지
- **비용이 아주 작을 때**(반올림하면 0 이 되는 값) — `$0.00` 으로 보이면 공짜처럼 오해되므로 `<$0.01` 같은 표기 필요
- **비용이 0 인 게 진짜일 때**(무료 모델·캐시 히트) — 위와 구분되어야 함
- 서버가 비용을 **안 내려주는 경우** — 전체 기능이 조용히 빈칸이 되어야 하고 에러나 깨진 UI 가 되면 안 됨
- 대화 도중 **모델이 바뀐 경우** — 답변마다 단가가 다르므로 누적은 답변별 값의 단순 합이어야 함(대표 단가로 재계산 금지)
- 통신이 끊겼다가 **폴링으로 답변이 뒤늦게 복구**된 경우 — 비용도 같이 복구되어야 하고 중복 합산되면 안 됨
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: 시스템은 완료된 각 답변에 대해 그 호출의 **비용**을 답변 옆(아래)에 보여줘야 한다
- **FR-002**: 시스템은 각 답변의 **토큰 수**를 계속 보여줘야 한다 (기존 표시 유지)
- **FR-003**: 시스템은 비용 값을 **서버가 알려준 값 그대로** 보여줘야 하며, 화면에서 임의로 다시 계산하지 않는다 [NEEDS CLARIFICATION: 비용을 서버가 실제로 내려주는가? 안 내려주면 화면에서 모델별 단가표로 계산해야 하는데 그건 범위가 달라짐]
- **FR-004**: 비용을 모르는 답변은 비용 부분만 생략하고 나머지 정보는 정상 표시해야 한다 (0 으로 대체 금지)
- **FR-005**: 시스템은 반올림하면 0 이 되는 아주 작은 비용을 "0" 이 아닌 별도 표기(예: 최소 표시 단위 미만)로 보여줘야 한다
- **FR-006**: 시스템은 대화방 단위 **누적 토큰·누적 비용**을 대화 화면 상단에서 보여줘야 한다
- **FR-007**: 누적값은 새 답변이 끝나는 즉시 갱신되어야 한다
- **FR-008**: 누적값은 개별 답변 값들의 단순 합이어야 하며, 값이 없는 답변은 합에서 빠지되 사용자가 "일부 누락"임을 알 수 있어야 한다
- **FR-009**: 예전 대화를 다시 열면 저장된 토큰·비용이 그대로 복원되어야 한다
- **FR-010**: 사용자는 토큰 표시에서 **입력 토큰과 출력 토큰을 나눠** 확인할 수 있어야 한다
- **FR-011**: 비용 표시 화폐 단위는 일관되어야 한다 [NEEDS CLARIFICATION: 달러 그대로 보여줄지, 원화로 바꿔 보여줄지 — 원화면 환율 출처와 갱신 주기가 추가 범위]
- **FR-012**: 이 기능은 기존 컨텍스트 점유 게이지(현재 점유 / 한도)와 **공존**해야 하며, 둘의 의미가 헷갈리지 않게 구분되어 보여야 한다
- **FR-013**: [NEEDS CLARIFICATION: 대화방을 넘어선 기간 단위 사용량(오늘/이번달 총 얼마)까지 이번 범위인지 — 이건 화면·저장·집계가 따로 필요해서 범위가 크게 달라짐]
### Key Entities
- **답변 사용량**: 답변 하나에 딸린 값 — 입력 토큰, 출력 토큰, 비용, 소요시간. 답변과 함께 저장되어 다시 열어도 남음
- **대화 누적 사용량**: 한 대화방 안 모든 답변 사용량의 합. 저장되는 값이 아니라 그때그때 더해서 보여주는 값
- **컨텍스트 점유량**: (기존) 다음 호출에 실릴 대화 길이 / 한도. 누적 사용량과 **다른 개념** — 누적은 계속 늘고, 점유량은 늘었다 줄 수 있음
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 답변이 끝난 뒤 사용자가 **추가 조작 없이** 그 답변의 비용을 확인할 수 있다 (클릭·이동 0회)
- **SC-002**: 사용자가 대화 하나의 총 지출을 확인하는 데 **3초 이내**, 화면 이동 없이 가능하다
- **SC-003**: 표시된 답변별 비용의 합과 화면에 표시된 누적 비용이 **100% 일치**한다
- **SC-004**: 비용 정보가 전혀 없는 환경에서도 대화 기능은 **아무 문제 없이** 동작하고, 화면에 깨진 표시나 오류 문구가 나오지 않는다
- **SC-005**: 예전 대화를 다시 열었을 때 표시되는 토큰·비용이 당시 값과 **100% 일치**한다
- **SC-006**: 비용 표시가 추가되어도 답변 렌더링 체감 속도에 **눈에 띄는 지연이 없다**
## Assumptions
- 이 기능은 **읽기 전용 표시**다. 예산 한도 설정·초과 시 차단·경고 알림 같은 건 이번 범위 밖
- 사용량 값의 진실원천은 **서버**다. 화면은 받은 값을 보여주기만 하고 스스로 토큰을 세지 않는다
- 사용자는 **자기 대화만** 본다. 다른 사람 사용량이나 팀 전체 집계는 범위 밖
- 기존 컨텍스트 점유 게이지는 **그대로 둔다**. 이번 작업은 거기에 비용을 얹는 것이지 갈아엎는 게 아님
- 표시 위치·모양은 기존 답변 하단 메타 줄과 헤더를 **재사용**한다. 새 패널·새 화면은 만들지 않음
- 비용이 화면에서 계산되어야 한다면(FR-003 이 아니라고 판명되면) 모델별 단가표를 어딘가 두고 관리해야 하며, 그건 **별도 범위**로 다시 잡는다
@@ -0,0 +1,39 @@
# Specification Quality Checklist: Tauri 데스크톱 앱 전환
**Purpose**: 계획 단계로 넘어가기 전 spec의 완성도·품질 확인
**Created**: 2026-08-11
**Updated**: 2026-09-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] 구현 세부(언어·프레임워크·API)가 목적에 필요한 만큼만 들어감 — 주석 참조
- [x] 사용자 가치·목적 중심
- [x] 비개발자도 읽을 수 있게 쓰임
- [x] 필수 섹션 전부 채움
## Requirement Completeness
- [x] `[NEEDS CLARIFICATION]` 표시 없음
- [x] 요구사항이 검증 가능하고 애매하지 않음
- [x] 성공 기준이 측정 가능함
- [x] 성공 기준이 사용자 결과 중심임
- [x] 수용 시나리오 전부 정의됨
- [x] 엣지 케이스 식별됨
- [x] 범위가 명확히 그어짐 — US1~US5 전부 완료
- [x] 의존성·가정 식별됨
## Feature Readiness
- [x] 기능 요구사항마다 수용 기준이 있음
- [x] 유저 스토리가 주요 흐름을 덮음
- [x] 성공 기준으로 달성 여부를 잴 수 있음
- [x] 미결정 사항 없음
## Notes
**기술 이름에 대하여**: 이 기능은 제품의 기준 데스크톱 앱을 Rust + Tauri로 전환하는 작업이라 Tauri와 legacy .NET의 이름을 숨길 수 없다. 구체 API와 코드 조립법은 spec이 아니라 plan에 남긴다.
**범위 변경**: 2026-09-09 사용자 결정으로 A/B 비교가 끝났다. Tauri가 기준 앱이고 .NET은 참고용 legacy다. 동시 실행·설치물 비교·중간 중단을 빼고 US1~US5 전체 완료를 필수로 바꿨다.
**스캐폴드**: `4_rust_tauri/`의 생성기 산출물은 이어서 쓰고, 손으로 쓴 Rust 소스는 컴파일하며 다시 확인한다.
@@ -0,0 +1,118 @@
# Contract: 브릿지 계약 → 환경별 전달 수단 매핑
**Created**: 2026-08-11 | **Updated**: 2026-09-11
## 이 문서의 위치
**메시지 계약의 진실원천은 여기가 아니다.** 원본은 아래 둘이고, 이 문서는 같은 메시지를 Tauri·참고용 legacy·브라우저가 어떻게 나르는지만 대응시킨다.
- `specs/001-snippet-palette/contracts/bridge-messages.md` — window.hide / paste.code / navigate / snippets.*
- `specs/002-capture-to-chat/contracts/bridge-capture.md` — capture.image
메시지의 의미·필드·에러 규칙을 바꿔야 한다면 원본을 먼저 고친다. Tauri 사정만으로 여기서 다른 계약을 만들지 않는다.
---
## 1. JS → 데스크톱 앱 (요청)
| 계약 메시지 | 참고용 legacy (.NET) | 기준 앱 (Tauri) |
|---|---|---|
| `{type:"window.hide"}` | `postMessage``type` 스위치 | `invoke("window_hide")` |
| `{type:"window.drag"}` | `postMessage``type` 스위치 | `invoke("window_drag")` |
| `{type:"window.snippetLayout", stage}` | 전송하지 않음 | `invoke("window_snippet_layout", { stage })` |
| `{type:"paste.code", text}` | `postMessage``type` 스위치 | `invoke("paste_code", { text })` |
| `{type:"route.changed", path}` | `postMessage``type` 스위치 | `invoke("report_route", { path })` |
| `{type:"snippets.list", reqId}` | `postMessage` + reqId 대기 | `invoke("snippets_list")` |
| `{type:"snippets.create", reqId, snippet}` | 〃 | `invoke("snippets_create", { snippet })` |
| `{type:"snippets.update", reqId, snippet}` | 〃 | `invoke("snippets_update", { snippet })` |
| `{type:"snippets.delete", reqId, name}` | 〃 | `invoke("snippets_delete", { name })` |
| `{type:"snippets.recordUse", reqId, name}` | 〃 | `invoke("snippets_record_use", { name })` |
### 커맨드 이름 규칙
계약의 `type`**점 → 밑줄, camelCase → snake_case** 로 바꾼 것. 기계적이라 외울 게 없다.
```
snippets.recordUse → snippets_record_use
route.changed → report_route ← 예외 1건
```
**`route.changed` 만 예외**인 이유: 이건 "무엇이 바뀌었다"는 통보라 `route_changed` 로 하면 커맨드가 아니라 이벤트처럼 읽힌다. 껍데기가 하는 일(보고 받기)을 이름에 담았다.
`window.snippetLayout`의 단계·크기·복원 계약은 스니펫 [원본 계약](../../001-snippet-palette/contracts/bridge-messages.md)의 Tauri 절을 따름.
### reqId는 Tauri에 없다
legacy는 요청·응답을 `reqId`로 짝맞춘다. `invoke`는 Promise를 돌려주므로 Tauri 경로에는 `reqId`가 실리지 않는다.
**그래도 프론트 표면은 같다**`request(type, payload)` 시그니처가 유지되므로 `snippets.api.ts`는 실행 환경을 모른다.
---
## 2. 데스크톱 앱 → JS (푸시)
| 계약 메시지 | 참고용 legacy (.NET) | 기준 앱 (Tauri) |
|---|---|---|
| `{type:"navigate", path}` | `PostWebMessageAsJson` | `emit("bridge", …)` |
| `{type:"paste.target", name, app}` | 〃 | 〃 |
| `{type:"capture.image", dataUrl}` | 〃 | 〃 |
### 채널은 하나, 페이로드는 동일
```jsonc
// .NET 이 보내는 것
{ "type": "navigate", "path": "/snippet" }
// Tauri 가 보내는 것 — 이벤트 이름만 "bridge", 페이로드는 글자 그대로 같음
{ "type": "navigate", "path": "/snippet" }
```
**메시지 종류마다 이벤트 이름을 나누지 않는다.** Tauri 관용으로는 `bridge:navigate`처럼 쪼개는 게 자연스럽지만, 그러면 프론트 `bridgeNavigate.ts``type` 분기를 다시 써야 한다. **한 채널로 몰면 그 분기와 `CustomEvent` dispatch를 그대로 재사용**할 수 있다.
### 필드 이름은 JS 쪽 표기를 따른다
`capture.image``dataUrl`**camelCase 그대로** 나간다. Rust 내부 변수명이 무엇이든 직렬화 결과가 계약과 같아야 한다.
---
## 3. 오류 표현
| 상황 | 참고용 legacy | Tauri | 프론트가 받는 것 |
|---|---|---|---|
| 정상 | `{ok:true, data}` | `invoke` resolve | 값 |
| 실패 (중복 이름 등) | `{ok:false, error:"한글 메시지"}` | `invoke` reject (한글 메시지) | `Error` |
| **아직 안 만듦** | 해당 없음 | `invoke` reject (한글 "아직 안 됨") | `Error` |
세 경우 모두 프론트에서는 `Error`로 도착해 react-query `onError` → sonner 토스트로 흐른다. 실행 환경별 분기가 화면에 생기지 않는다.
### 미구현은 성공을 흉내내지 않는다
Tauri에서 아직 안 만든 기능은 빈 값(`[]`, `null`)으로 답하지 않는다. 구현 중 빠진 기능을 정상 결과와 구분할 수 있어야 한다.
---
## 4. 껍데기 없음 (브라우저)
기존 동작 그대로. 계약상 변화 없음.
| 방향 | 동작 |
|---|---|
| JS → 껍데기 (단방향) | 조용히 no-op |
| JS → 껍데기 (응답 필요) | 즉시 reject — "데스크톱 전용" |
| 껍데기 → JS | 안 옴 |
`npm run dev` 로 브라우저에서 화면만 열어도 안 깨져야 한다 (FR-003).
---
## 5. 검증
이 매핑이 맞는지는 아래로 확인한다. 자동화 대상과 수동 대상이 갈린다.
| 항목 | 방법 |
|---|---|
| 호스트 3종 판별 | 자동 (vitest, mock) |
| 브라우저에서 no-op / reject | 자동 (vitest) |
| legacy adapter 동작 | 자동 (기존 브릿지 테스트 3개) |
| Tauri에서 실제 왕복 | 수동 ([quickstart.md](../quickstart.md)) |
| Tauri와 브라우저가 같은 화면 결과물 사용 | 빌드 설정 + 실제 실행 확인 |
+116
View File
@@ -0,0 +1,116 @@
# Phase 1: Data Model — Tauri 기준 데스크톱 앱
**Created**: 2026-08-11 | **Updated**: 2026-09-09 | **Plan**: [plan.md](./plan.md)
이 기능은 **새 데이터를 거의 안 만든다.** 기존 스니펫을 보존하고 Tauri의 창·화면 상태만 관리한다.
---
## 1. Snippet — 기존 데이터 유지
**저장소**: `%LocalAppData%\CodeAssist\snippets.db` (SQLite)
**출처**: `3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`가 현재 형식의 참고 원본이다. Tauri는 **스키마를 바꾸지 않고** 기존 파일을 직접 쓴다.
| 필드 | 타입 | 규칙 |
|---|---|---|
| `name` | string | **키**. 중복 생성 거부 |
| `desc` | string | 설명 |
| `body` | string | 본문. 붙여넣을 때 **원문 그대로** (FR-013) |
| `category` | string | 분류 |
| `usageCount` | int | 사용할 때마다 +1 |
| `lastUsed` | timestamp | 사용 시각 |
**검증 규칙**:
- `name` 중복 생성 → 거부, 한글 오류 메시지
- 빈 필수 필드 → 거부, 한글 오류 메시지
- 오류는 화면에 토스트로 뜬다 (FR-022)
**상태 변화**: 생성 → (수정)\* → (사용 시 `usageCount`+1, `lastUsed` 갱신)\* → 삭제
**보존 규칙**:
- 스키마 변경 금지
- SQLite 저널 모드 변경 금지
- 기존 파일이 없으면 같은 형식의 빈 저장소를 만든다
---
## 2. WindowState — Tauri 앱 상태
| 필드 | 의미 |
|---|---|
| 위치 (x, y) | 마지막 창 자리 |
| 크기 (w, h) | 마지막 창 크기 |
| `pinned` | 항상 위 고정 여부 |
**저장 위치**: `%LocalAppData%\com.codeassist.app\`. 위치·크기는 window-state 플러그인, `pinned`는 별도 단일 파일로 저장한다.
**복원 규칙**: 화면 밖 자리로는 복원하지 않는다. 모니터 구성이 바뀌어 저장된 자리가 화면 밖이면 보이는 자리로 되돌린다.
---
## 3. PasteTarget — 기존, 휘발성
창을 부르기 **직전**에 한 번 찍는 스냅샷. 저장 안 함(앱 종료 시 사라짐).
| 필드 | 의미 |
|---|---|
| 창 핸들 | 붙여넣을 대상 |
| `name` | 창 제목 |
| `app` | 프로그램 이름 (예: `Code`, `notepad`) |
**왜 스냅샷인가**: 나중에 그 창이 닫혀도 "어디에 붙는지" 표시는 남아야 한다(FR-015). 그래서 소환 시점에 제목·프로그램명을 **값으로 복사**해 둔다.
**생명주기**: 창 소환 시 갱신 → 화면에 배지로 표시 → 다음 소환 때 덮어씀
---
## 4. Routes — 기존, 껍데기 메모리
화면이 이동할 때마다 껍데기에 보고하는 값. 챗봇 단축키 토글 규칙(FR-011)에 쓴다.
| 필드 | 의미 | 초기값 |
|---|---|---|
| `current` | 화면이 지금 있는 자리 | `/snap` |
| `lastSnap` | 마지막으로 있던 **비-스니펫** 자리 | `/snap` |
**갱신 규칙**: 보고받은 경로가 `/snippet` 으로 시작하지 **않으면** `lastSnap` 도 같이 갱신. `current` 는 항상 갱신.
**토글 판정**:
```
창이 보이는 중 AND current 가 /snippet 이 아님 → 숨긴다
그 외 → 띄우고 lastSnap 으로 이동
```
---
## 5. BridgeMessage — 기존 계약
**진실원천은 여기가 아니다.** 아래 두 문서가 원본:
- `specs/001-snippet-palette/contracts/bridge-messages.md`
- `specs/002-capture-to-chat/contracts/bridge-capture.md`
환경별 전달 수단 매핑만 [contracts/transport-mapping.md](./contracts/transport-mapping.md)에 있다.
**이 기능이 계약에 더하는 제약 1개**:
> Tauri에서 아직 안 만든 메시지는 성공 응답을 흉내내지 않는다. 사람이 읽을 수 있는 "아직 안 됨" 오류로 답하고, 화면은 그걸 토스트로 띄운다. (FR-023)
**왜 이게 데이터 규칙인가**: 빈 목록(`[]`)으로 답하면 "스니펫이 0개"와 "기능이 없음"이 구분되지 않는다. 구현 중 빠진 기능을 성공으로 오판하지 않게 한다.
---
## 6. HostKind — 프론트 내부
프론트가 bridge 사용 시점에 판별하는 값. 저장하지 않는다.
| 값 | 판별 조건 | 동작 |
|---|---|---|
| `tauri` | `typeof window.__TAURI_INTERNALS__?.invoke === "function"` | 기준 데스크톱 앱 방식 |
| `webview2` | `window.chrome?.webview` 있음 | 참고용 legacy 방식 |
| `browser` | 둘 다 없음 | 데스크톱 기능 없음 |
**판별 순서 고정**: `tauri``webview2``browser`. Windows Tauri가 WebView2를 사용해도 Tauri invoke 통로를 먼저 고른다.
**노출 범위**: `isWebView()` 같은 기존 함수의 속만 바뀐다. 화면 코드는 `HostKind`를 몰라야 한다(FR-002, SC-001).
---
+170
View File
@@ -0,0 +1,170 @@
# Implementation Plan: Tauri 기준 데스크톱 앱 전환
**Branch**: `004-tauri-shell` | **Created**: 2026-08-11 | **Updated**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/004-tauri-shell/spec.md`
## Summary
프론트(`2_frontend`)에 **호스트 자동 감지 seam** 하나를 두고, 기준 데스크톱 앱인 `4_rust_tauri`(Tauri v2)에 현재 화면·창 제어·붙여넣기·캡쳐·스니펫 저장을 전부 연결한다. 기존 `3_windowsApp`(.NET)은 삭제하지 않지만 참고용 legacy로만 남긴다.
핵심 설계는 "기존 브릿지 계약을 Tauri 수단으로 그대로 나른다"는 것. Rust→JS 푸시를 **단일 이벤트 채널에 `{type, ...}` 그대로** 실어보내면 프론트 `bridgeNavigate.ts``type` 분기·CustomEvent dispatch 로직을 재사용할 수 있다. JS→Rust는 `invoke`의 Promise를 쓴다.
**프론트 빌드·dist는 한 벌**만 유지한다. Tauri 앱과 브라우저 개발 화면이 같은 결과물을 쓰고, 참고용 WebView2 adapter도 transport 안에 남긴다.
## Technical Context
**Language/Version**: TypeScript 5.6 (프론트) / Rust 2021 edition (기준 데스크톱 앱)
**Primary Dependencies**:
- 프론트 추가: `@tauri-apps/api` v2 — 유일한 신규 프론트 의존성
- Rust: `tauri` 2 (tray-icon, image-png), `tauri-plugin-global-shortcut`, `tauri-plugin-single-instance`, `tauri-plugin-window-state`, `rusqlite`(bundled), `windows`(Win32 붙여넣기·캡쳐), `serde`
- legacy .NET: 변경·새 의존성 없음
**Storage**:
- 스니펫: `%LocalAppData%\CodeAssist\snippets.db` — 기존 사용자 데이터를 그대로 이어서 씀(FR-020)
- 창 위치·크기: `tauri-plugin-window-state` 경로(`%LocalAppData%\com.codeassist.app\`)
- 핀(항상 위) 상태: 위 폴더의 단일 파일. window-state 플러그인이 always-on-top은 저장 안 함
**Testing**:
- 프론트: `vitest` — transport seam 단위 테스트 + 기존 브릿지 테스트 3개 mock 교체
- Rust: `cargo test` — 순수 로직(챗봇 토글 규칙, 스니펫 검증)
- 수동: Tauri 창·트레이·표준 단축키·붙여넣기·멀티모니터 캡쳐를 `quickstart.md`에서 실제 실행
- 브라우저: 개발 화면과 데스크톱 기능 없는 경로를 실제 브라우저에서 확인
**Target Platform**: Windows 11. WebView2 런타임 사용
**Project Type**: Rust + Tauri 데스크톱 앱 + React SPA 프론트. .NET 앱은 참고용 legacy
**Performance Goals**: 별도 계측 목표 없음. 전역 단축키와 붙여넣기·캡쳐 흐름에서 사용자가 느끼는 멈춤이나 무반응이 없어야 함
**Constraints**:
- Tauri가 표준 단축키 `Ctrl+Shift+7/8/9`를 소유함
- legacy 앱과 동시 실행은 지원하지 않음
- 프론트 소비자 코드 변경 **0줄** (SC-001)
- US1~US5 전부 완료하며 미구현 성공 응답을 남기지 않음
- 기본 실행·배포·검증에서 .NET을 요구하지 않음 (SC-008)
**Scale/Scope**: 프론트 변경은 `lib/bridge/` 4파일 + 관련 테스트로 국한. 데스크톱 기능은 `4_rust_tauri` 안에서 완성
## Constitution Check
*GATE: 구현 전에 통과했고 설계 갱신 후 다시 확인함.*
`.specify/memory/constitution.md`는 빈 템플릿이다. CLAUDE.md 규칙을 게이트로 쓴다.
| 게이트 (출처) | 판정 | 근거 |
|---|---|---|
| **재사용 우선** (CLAUDE.md 서두) | ✅ | 프론트는 기존 브릿지 계약·분기·소비자 코드를 재사용하고, Rust는 기존 스캐폴드와 검증된 .NET 동작 순서를 참고함 |
| **최소 diff / YAGNI** (ponytail) | ✅ | 프론트 신규 파일은 transport 하나. 창 상태·단일 인스턴스·단축키는 공식 플러그인을 씀 |
| **API 호출은 `@/lib/api/client`만** (§4) | ✅ 해당없음 | 서버 API를 건드리지 않음 |
| **서버 DTO는 `src/types/api`에만** (§4) | ✅ 해당없음 | 브릿지 타입은 서버 DTO가 아님 |
| **주석·문서 한글 반말** (§0, §4) | ✅ | feature 문서와 새 주석을 한글로 유지 |
| **편집 후 format, 완료 전 lint+test+build** (§4) | ⏳ 실행 시 | tasks와 quickstart에 검증 순서를 둠 |
| **docs-lib 선참조** (§3) | ✅ | Tauri v2와 프론트 API 문서 박제 완료 |
| **plan 집은 하나** (§1) | ✅ | `specs/004-tauri-shell/`만 사용 |
| **버그는 systematic-debugging 먼저** (§1) | ✅ | 구현 중 실패가 나오면 적용 |
**게이트 결과**: 통과. 신규 프론트 의존성 1개는 Tauri 내부 프로토콜을 직접 흉내내지 않기 위한 정당한 편차다.
## Project Structure
### Documentation (this feature)
```text
specs/004-tauri-shell/
├── plan.md # 이 파일
├── research.md # Phase 0 — 기술 결정 12건
├── data-model.md # Phase 1 — 엔티티·상태
├── quickstart.md # Phase 1 — Tauri 단독 실행·검증 시나리오
├── contracts/
│ └── transport-mapping.md # Phase 1 — 기존 계약 → Tauri·legacy·브라우저 수단 매핑
├── checklists/
│ └── requirements.md # spec 품질 체크리스트 (완료)
└── tasks.md # Phase 2 — /speckit-tasks 가 만듦 (여기선 안 만듦)
```
**기존 계약 문서는 여기 복사하지 않는다.** 진실원천은 그대로 아래 둘이고, `contracts/transport-mapping.md` 는 그걸 **가리키면서** 껍데기별 수단만 대응시킨다.
- `specs/001-snippet-palette/contracts/bridge-messages.md`
- `specs/002-capture-to-chat/contracts/bridge-capture.md`
### Source Code (repository root)
```text
2_frontend/ # Tauri와 브라우저가 공유. 빌드 한 벌
├── src/lib/bridge/
│ ├── transport.ts # [신규] 호스트 감지 + 전송수단 3종
│ ├── webviewBridge.ts # [수정] transport 위로 얇게. export 시그니처 불변
│ ├── snippetBridge.ts # [축소] 요청 표면만 남김
│ ├── bridgeNavigate.ts # [수정] 리스너 부착만 교체, 분기 로직 그대로
│ └── *.test.ts # [수정] transport mock 사용
├── src/features/** # [무변경] 소비자 8곳 — SC-001
└── docs-lib/ # Tauri 프론트 API 박제
3_windowsApp/ # [무변경] 참고용 legacy
└── ...
4_rust_tauri/ # 기준 데스크톱 앱
├── docs-lib/
│ ├── README.md # [신규] 카탈로그
│ └── tauri-v2.md # [신규] API 박제 — 첫 Rust 코드보다 먼저 (R8)
├── package.json # tauri CLI 만
└── src-tauri/
├── Cargo.toml
├── tauri.conf.json # 제품명 CodeAssist, devUrl → 2_frontend:15173, frontendDist → dist
├── capabilities/default.json
├── icons/ # 생성기 산출물 — 유지
└── src/
├── main.rs # 진입점
├── lib.rs # 조립부 ← .NET App.xaml.cs 대응
├── shell/ # 프로젝트 무관 재사용 층 ← .NET CodeAssist.Shell 대응
│ ├── mod.rs
│ ├── hotkey.rs # US2
│ ├── tray.rs # US2
│ ├── window.rs # US2
│ ├── paste.rs # US3
│ └── capture.rs # US4
├── bridge/ # 계약 층
│ ├── mod.rs # 푸시 타입 + emit
│ └── commands.rs # #[tauri::command] 모음
└── storage/
└── snippets.rs # US5 — rusqlite, .NET 과 같은 DB 파일
```
**Structure Decision**:
기준 데스크톱 앱은 `4_rust_tauri`다. 그 안에서는 기존 .NET 구현에서 검증된 책임 경계를 참고하되 Tauri 플러그인과 Rust 코드에 맞게 최소로 유지한다.
- `shell/` = CodeAssist를 모르는 Windows/Tauri 동작 층
- `lib.rs` = 어느 단축키가 무엇을 하는지와 화면 이동 규칙을 모으는 조립부
- `bridge/` = 기존 메시지 계약을 Tauri `invoke`/`emit`으로 나르는 층
통합 진입점(`shell::init(config)`)은 만들지 않는다. 세 모듈이 서로 독립이고 콜백도 달라서 감싸면 넘겨주기만 하는 코드가 늘어난다.
## Constitution Check — 설계 후 재확인 (Phase 1 완료 시점)
| 게이트 | 판정 | 설계로 확인된 것 |
|---|---|---|
| **재사용 우선** | ✅ | `bridgeNavigate.ts`의 분기·dispatch와 기존 메시지 계약을 그대로 쓰고, WebView2 요청 짝맞춤은 legacy adapter 안으로 이동함 |
| **최소 diff / YAGNI** | ✅ | 프론트 신규 파일 1개. 구현 하나짜리 통합 진입점과 자동 계측 장치를 만들지 않음 |
| **docs-lib 선참조** | ✅ | Tauri v2·프론트 invoke/listen 표면과 DPI 동작 확인 완료 |
| **주석·문서 한글 반말** | ✅ | 산출물 전부 한글 |
| **plan 집은 하나** | ✅ | `specs/004-tauri-shell/`만 사용하고 기존 계약 문서는 링크함 |
| **완료 전 lint+test+build** | ⏳ | quickstart와 tasks에 프론트·Rust·Tauri release 검증을 둠 |
**설계가 드러낸 위험 1건**:
> `2_frontend`의 브릿지 테스트 3개가 `chrome.webview` mock에 의존한다. transport mock으로 바꾸면서 실제 Tauri 경로가 아니라 mock끼리만 맞는 상태가 생길 수 있다.
>
> **완화**: 자동 검사 뒤 브라우저와 실제 Tauri 앱을 각각 실행한다. 기존 WebView2 경로는 adapter 단위 테스트로 남기되 legacy 앱 수동 회귀를 완료 게이트로 두지 않는다.
**게이트 결과**: 통과. 아래 Complexity Tracking의 2건은 근거 있는 편차다.
## Complexity Tracking
> Constitution Check 위반 중 정당화가 필요한 것만
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| 프론트 신규 의존성 `@tauri-apps/api` | `invoke`/`listen`은 Tauri 내부 프로토콜이라 공식 API를 쓰는 편이 작고 안전함 | 직접 구현은 버전마다 깨질 내부 표면을 소유하게 됨 |
| `4_rust_tauri`의 미완성 스캐폴드 유지 | 아이콘·`build.rs`·capabilities는 생성기가 만든 유효한 산출물 | 통째 재생성해도 같은 파일만 다시 생김. 손으로 쓴 소스만 컴파일하며 재검증함 |
+144
View File
@@ -0,0 +1,144 @@
# Quickstart: Tauri 기준 앱 확인하기
**Date**: 2026-09-09 | **Plan**: [plan.md](./plan.md)
이 문서는 검증용 실행 안내다. 구현 방법은 `tasks.md`와 코드에 있다.
---
## 사전 준비
- Windows 11 + WebView2 런타임
- Node 26 / npm 11
- Rust stable MSVC 툴체인
- VS C++ 도구 + Windows 11 SDK
```powershell
rustc --version
cargo --version
```
---
## A. 자동 검사
```powershell
cd 2_frontend
npm install
npm run format
npm run lint
npm run test
npm run build
cd ..\4_rust_tauri
npm install
cargo test --manifest-path src-tauri\Cargo.toml
```
**기대**: 전부 통과하고 오류·경고로 숨긴 실패가 없다.
---
## B. 브라우저 개발 화면
```powershell
cd 2_frontend
npm run dev
```
`http://localhost:15173`에서 확인:
| 확인 | 기대 |
|---|---|
| 챗봇 화면 | 정상 표시 |
| 콘솔 오류 | 없음 |
| 코드블록의 붙여넣기 버튼 | 안 보임 |
| `/snippet` 직접 이동 | 화면은 뜨고 요청은 "데스크톱 전용" 오류 |
---
## C. Tauri 앱 기본 동작
```powershell
cd 4_rust_tauri
npm run tauri dev
```
| 확인 | 기대 |
|---|---|
| 창 | 제품명 `CodeAssist`, 제목표시줄 없음 |
| 화면 | `2_frontend`의 현재 화면 |
| 트레이 | 열기 / 항상 위에 고정 / 종료 |
| `Ctrl+Shift+7` | 스니펫 팔레트 |
| `Ctrl+Shift+8` | 챗봇 토글 |
| `Ctrl+Shift+9` | 캡쳐 시작 |
| 두 번 실행 | 새 창 없이 기존 창 소환 |
| 창 이동·크기·핀 뒤 재실행 | 마지막 상태 복원 |
단축키 등록 실패가 있으면 앱은 계속 돌고 실패한 조합을 알려야 한다.
---
## D. 붙여넣기
대상 앱마다 커서를 둔 뒤 `Ctrl+Shift+7` → 스니펫 선택 → Enter:
| 대상 | 기대 |
|---|---|
| 메모장 | 선택한 코드가 원문 그대로 붙음 |
| VS Code | 선택한 코드가 원문 그대로 붙음 |
| 창 전환이 느린 업무용 앱 | 엉뚱한 창이나 일부만 붙는 일이 없음 |
추가 확인:
- 붙여넣기 대상 배지에 직전 앱 이름이 보임
- 자동 붙여넣기가 실패해도 클립보드에 코드가 남음
느린 앱에서 어긋나면 research R10의 80ms 대기 값을 실제 환경에 맞게 조정한다.
---
## E. 화면 캡쳐
| 상황 | 확인 |
|---|---|
| 배율 100% 모니터 | `Ctrl+Shift+9` → 영역 드래그 → 고른 영역과 결과 일치 |
| 배율 150% 모니터 | 같은 흐름에서 결과가 밀리거나 잘리지 않음 |
| 두 모니터에 걸친 영역 | 고른 영역과 결과 일치 |
| Esc | 첨부 없이 화면 복귀 |
| 드래그 없이 클릭 | 조용히 취소 |
캡쳐한 그림을 원본 화면과 나란히 놓고 경계를 확인한다. 어긋나면 DPI 설정이 아니라 논리↔물리 좌표 변환을 본다.
---
## F. 기존 스니펫
1. `%LocalAppData%\CodeAssist\snippets.db`가 있는 상태에서 Tauri 팔레트를 연다.
2. 기존 목록이 보이는지 확인한다.
3. 스니펫 하나를 만들거나 수정하고 하나를 사용한다.
4. 앱을 다시 켠다.
5. 변경 내용·사용 횟수·최근 사용 순서가 유지되는지 확인한다.
기존 DB가 없으면 빈 목록으로 시작해 새 스니펫을 저장할 수 있어야 한다. 스키마와 저널 모드는 바꾸지 않는다.
---
## G. 미구현 처리
구현 중 아직 연결하지 않은 요청은 빈 값으로 성공한 척하지 않고 한글 "아직 안 됨" 오류를 보여야 한다. 최종 완료 때는 이 오류를 내는 필수 기능이 남아 있으면 안 된다.
---
## H. release
```powershell
cd 4_rust_tauri
npm run tauri build
```
**기대**:
- release 설치물이 만들어짐
- 설치물과 실행 창의 제품명이 `CodeAssist`
- 빌드·실행·검증에 `3_windowsApp`이 필요하지 않음
마지막으로 `specs/004-tauri-shell/report.md`에 계획 대비 구현 결과와 검증 증거를 남긴다.
+201
View File
@@ -0,0 +1,201 @@
# Phase 0: Research — Tauri 기준 데스크톱 앱
**Created**: 2026-08-11 | **Updated**: 2026-09-09 | **Plan**: [plan.md](./plan.md)
기술 결정 12건. 각 항목은 **결정 / 왜 / 버린 대안** 형식.
---
## R1. 개발 환경: Rust + MSVC 준비 완료
**현황 (2026-08-15 확인)**: `rustup` + `stable-x86_64-pc-windows-msvc`, VS 2026 Community의 C++ 도구와 Windows 11 SDK 설치가 끝났다. `rustc`로 실제 링크까지 통과했다.
**결정**: 현재 MSVC 툴체인을 그대로 쓴다. 별도 Build Tools나 GNU 툴체인을 추가하지 않는다.
**왜**: Tauri와 Win32 API를 이 PC의 실제 Windows 환경에서 빌드·검증할 수 있다. 이미 준비된 도구 외 설치는 필요 없다.
**영향 범위**: 모든 Rust 작업을 바로 시작할 수 있다.
---
## R2. 이미 있는 미완성 스캐폴드를 어떻게 할 것인가
**현황**: `4_rust_tauri/``create-tauri-app` 으로 뽑은 뼈대 + 손으로 쓴 소스가 섞여 있다. `Cargo.toml` 의 lib 이름은 `codeassist_tauri_lib` 인데 `main.rs` 는 생성기 기본값 `_rust_tauri_lib::run()` 을 부른다 → **현재 빌드 불가**.
**결정**: **이어 쓴다.** 단 자산을 두 등급으로 나눠 다르게 취급한다.
| 등급 | 대상 | 취급 |
|---|---|---|
| 신뢰 | `icons/`(13개), `build.rs`, `capabilities/`, `.gitignore` | 그대로 유지. 생성기 산출물이라 검증됨 |
| **재검증 대상** | 손으로 쓴 `src/**/*.rs`, `Cargo.toml`, `tauri.conf.json` | **컴파일 검증 0줄.** 첫 빌드에서 전부 다시 본다 |
**왜**: 아이콘 13종(.ico/.icns/.png 다중 해상도)은 손으로 못 만들고, 지우고 재생성해도 **같은 생성기를 다시 돌리는 것**이라 얻는 게 없다. 반대로 손으로 쓴 Rust 는 한 줄도 컴파일 안 해봤으므로 "있으니 맞겠지"로 넘기면 안 된다.
**버린 대안**: 통째로 지우고 재생성 — 아이콘을 다시 뽑아야 하고 결과물이 같음. 순수 낭비.
**첫 할 일**: `main.rs` 의 lib 이름 한 줄 정합.
---
## R3. Tauri v2 API 를 기억으로 쓰지 않는다 (docs-lib)
**결정**: 첫 Rust 코드를 쓰기 **전에** `4_rust_tauri/docs-lib/tauri-v2.md` 를 만들고, 실제로 쓸 API 표면을 거기 박제한 뒤 그걸 보고 짠다. `2_frontend/docs-lib/tauri-api.md` 도 같이(프론트 `invoke`/`listen` 용).
**왜**: CLAUDE.md §3 의 명시 규칙이기도 하지만, 이 건에서 특히 위험하다. Tauri v2 는 **2.x 안에서도 시그니처가 바뀐 자리**가 있다 — 특히:
- `global_shortcut().on_shortcut()` 핸들러 인자 개수 (2개 → 3개)
- 트레이 `show_menu_on_left_click` 의 이름·존재 여부
- `Emitter`/`Manager` trait 를 어디서 import 하는지
이걸 기억으로 쓰면 첫 빌드가 에러 벽에 부딪히고, 그때 "내가 틀린 건지 버전이 다른 건지"를 구분 못 해 시간을 크게 잃는다.
**박제할 표면**: 플러그인 4종 init, `TrayIconBuilder`, `Menu`/`CheckMenuItem`, `WebviewWindow`(show/hide/set_focus/set_always_on_top/start_dragging/is_visible), `#[tauri::command]` + `State`, `app.emit`, `app.path().app_local_data_dir()`, capabilities 권한 이름.
**버린 대안**: 일단 짜고 컴파일 에러로 배우기 — 에러 메시지가 매크로 안에서 나면 원인이 안 보인다. 특히 `generate_handler!` 안 에러는 해독이 어렵다.
---
## R4. 프론트 호스트 감지: 사용 시점 자동 판별, 빌드는 한 벌
**결정**: bridge를 사용할 때 작동하는 통로를 판별한다. 별도 빌드 플래그나 테스트 전용 reset API는 만들지 않는다.
| 호스트 | 판별 | 전송 수단 |
|---|---|---|
| Tauri | `typeof window.__TAURI_INTERNALS__?.invoke === "function"` | `invoke()` + `listen()` |
| 참고용 legacy | `window.chrome?.webview` 있음 | `postMessage` + `addEventListener("message")` |
| 없음(브라우저) | 둘 다 없음 | 단방향 no-op / 응답 필요는 즉시 reject |
**판별 순서**: `tauri``webview2``browser`. Windows의 Tauri도 WebView2 위에서 돌 수 있으므로 Tauri의 실제 invoke 통로를 먼저 확인한다.
**왜 사용 시점인가**: 호스트 확인은 속성 두 개를 보는 작은 작업이고, 테스트가 런타임에 mock을 심어도 별도 재판별 훅 없이 실제 흐름을 검증할 수 있다. 앱에서는 호스트가 바뀌지 않으므로 결과도 결정적이다.
**버린 대안**:
- Vite `define` / 환경변수 — 브라우저 개발 화면과 Tauri가 서로 다른 번들을 갖게 됨
- 모듈 로드 때 상수로 고정 + reset API — 테스트만 위한 공개 표면이 생김
- 공식 `isTauri()` — 별도 `window.isTauri` 플래그를 보므로 실제 invoke 가능 여부보다 약함
---
## R5. Rust→JS 푸시: 단일 채널에 `{type, ...}` 그대로
**결정**: 이벤트 이름은 `"bridge"` 하나. 페이로드는 기존 계약과 같은 모양을 유지한다.
```
app.emit("bridge", { "type": "navigate", "path": "/snippet" })
```
**왜**: `bridgeNavigate.ts` 안의 `navigate` / `paste.target` / `capture.image` 분기와 `CustomEvent` dispatch를 그대로 재사용할 수 있다. 바뀌는 건 리스너를 붙이는 transport뿐이다.
**버린 대안**: 메시지 종류마다 이벤트 이름 분리 — Tauri 관용에는 가깝지만 기존 분기 로직을 다시 쓰게 되어 코드가 늘어난다.
---
## R6. JS→Rust: `invoke` 가 reqId 인프라를 대체한다
**결정**: `snippetBridge.ts` 의 reqId 채번·`pending` Map·리스너 필터를 **삭제**하고, `request(type, payload)` 시그니처만 남겨 `invoke(커맨드명, payload)` 로 넘긴다.
**왜**: 요청↔응답 짝맞춤은 `invoke` 가 Promise 로 이미 해준다. 그 위에 또 얹으면 같은 일을 두 번 한다. `snippets.api.ts``request("snippets.list")` 를 그대로 부르므로 **호출부 무변경**.
**타입명 → 커맨드명** 매핑 표는 `contracts/transport-mapping.md`.
**주의**: 기존 껍데기 경로에서는 reqId 인프라가 **그대로 필요하다**. 그래서 이 코드는 지우는 게 아니라 `transport.ts` 의 webview2 구현 안으로 **옮긴다**.
---
## R7. 단축키: Tauri가 표준 조합을 소유한다
**결정**: Tauri 앱은 `Ctrl+Shift+7/8/9`를 쓴다. 각각 스니펫·챗봇·캡쳐다.
**왜**: Tauri가 기준 앱이므로 사용자가 익숙한 조합을 그대로 이어받는다. 참고용 legacy 앱과 동시 실행은 지원하지 않는다.
**같이 결정**: 등록 실패는 앱을 죽이지 않고 어떤 조합이 실패했는지 알린다(FR-010).
---
## R8. 상태와 기존 데이터 경로
**결정**:
| 데이터 | 경로 | 처리 |
|---|---|---|
| 창 위치·크기·핀 | `%LocalAppData%\com.codeassist.app\` | 제품용 identifier에 저장 |
| 스니펫 DB | `%LocalAppData%\CodeAssist\snippets.db` | 기존 파일을 직접 열어 이어서 사용 |
**왜**: 창 상태는 Tauri 제품 설정으로 관리하고, 사용자가 쌓은 스니펫은 별도 이전 없이 보존해야 한다(FR-020).
**스니펫 규칙**: 기존 스키마와 저널 모드를 바꾸지 않는다. legacy 코드는 참고 원본으로 남아 있으므로 데이터 형식을 함부로 갈라놓지 않는다.
**버린 대안**: 새 DB로 복사 — 데이터가 두 벌로 갈리고 이후 변경이 나뉜다.
---
## R9. 창 상태 저장은 플러그인에 맡긴다
**결정**: `tauri-plugin-window-state` 를 쓰고, 화면 밖 복원 방지도 플러그인 기본 동작에 맡긴다. 다만 **핀(항상 위) 상태만** 직접 저장한다(플러그인이 always-on-top 은 안 봄).
**왜**: .NET 판의 `JsonWindowPlacementStore`(46줄) + `WindowPlacement.IsVisibleWithin`(가상 화면 겹침 판정)이 통째로 사라진다. 직접 짤 이유가 없다.
**버린 대안**: 직접 구현해서 .NET 과 동형 유지 — 동형일 필요가 없다. 사용자에게 보이는 결과(껐다 켜면 그 자리)만 같으면 됨.
---
## R10. 붙여넣기: .NET 이 박제한 함정을 그대로 옮긴다
**참조 원본**: `3_windowsApp/CodeAssist.Shell/Platform/PasteService.cs` — 주석에 "**함정 박제**"라고 적혀 있는 자리.
**결정**: 순서를 **그대로** 지킨다.
1. 클립보드에 코드 적재
2. **우리 창을 숨기기 전에** 직전 창 활성화 (`SetForegroundWindow`)
3. 짧게 대기 (창 전환 느린 대상 대비)
4. `Ctrl+V` 주입
5. 클립보드 원복 **안 함** — 붙인 내용을 남긴다(사용자 요청 사항)
**왜 순서가 중요한가**: Windows 의 포그라운드 락 때문에 `SetForegroundWindow`**우리 창이 떠 있을 때만** 먹힌다. 창을 먼저 숨기면 실패한다. 이건 .NET 판에서 이미 한 번 당한 자리라, 새로 설계하지 말고 베낀다.
**대기 시간**: .NET 은 80ms. **그대로 시작하되 조정 가능한 값으로 둔다** — 물리적 타이밍이라 환경 따라 다를 수 있고, 최소 코드로는 안 보이는 종류의 값이다.
**실패 시**: 붙이기는 포기하되 **코드는 클립보드에 남긴다** → 사용자가 직접 `Ctrl+V` (FR-014).
**Rust 수단**: `windows` crate 로 `SetForegroundWindow` / `keybd_event`(또는 `SendInput`) 직접 호출. 플러그인 없음.
---
## R11. 캡쳐: DPI 인지와 좌표 변환
**참조 원본**: legacy 앱의 캡쳐 구현과 `4_rust_tauri/docs-lib/tauri-v2.md` §8.
**결정**: Tauri 런타임이 이미 켜는 PerMonitorV2 DPI 인지를 그대로 쓰고, 오버레이는 전체 가상 화면을 덮는 투명·항상위 창으로 만든다. 드래그로 고른 논리 좌표를 물리 픽셀로 변환한 뒤 해당 영역을 자른다.
**왜**: 배율이 다른 모니터에서 논리 좌표를 그대로 픽셀로 쓰면 고른 영역과 결과가 어긋난다. 별도 DPI 매니페스트는 필요 없다.
**Rust 수단**: `windows` crate로 `BitBlt`를 직접 호출한다. 기존 Windows 동작을 가장 적은 의존성으로 옮길 수 있다.
---
## R12. 테스트 전략: 자동으로 될 것만 자동으로
**결정**:
| 대상 | 방식 | 근거 |
|---|---|---|
| 프론트 transport 선택·브라우저 동작 | `vitest` 자동 | 순수 분기이며 호스트 3종을 mock으로 태울 수 있음 |
| 기존 브릿지 테스트 3개 | `vitest` — transport mock으로 교체 | 화면 소비자 동작을 유지하는 회귀 검사 |
| 챗봇 토글 규칙, 스니펫 검증 | `cargo test` 자동 | 순수 로직 |
| 창·트레이·핫키 | 실제 Tauri 앱 수동 확인 | OS 자원 테스트 하네스가 본체보다 커짐 |
| 붙여넣기·캡쳐 | 실제 Windows 환경 수동 확인 | 포커스·모니터 배율에 달림 |
**왜 실제 Tauri 확인을 붙이나**: transport mock만 맞고 실제 invoke/listen 권한이나 이벤트 연결이 깨진 상태를 자동 검사만으로는 잡을 수 없다. 브라우저 확인 뒤 Tauri 앱을 직접 실행한다.
---
## 미해결로 남기는 것
| 항목 | 왜 지금 안 정하나 |
|---|---|
| 붙여넣기 대기 시간 최종값 (R10) | 물리 타이밍. 80ms로 시작하고 실기기에서 조정 |
### R11 DPI 인지 — 해소 (2026-08-15)
docs-lib(`4_rust_tauri/docs-lib/tauri-v2.md` §8)에서 확인했다. `tao` 0.35.3이 이벤트 루프를 만들 때 `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`를 호출하므로 우리가 별도 설정할 것은 없다.
**귀결**: US4에서 좌표가 어긋나면 DPI 설정이 아니라 논리↔물리 좌표 변환을 본다.
+229
View File
@@ -0,0 +1,229 @@
# Feature Specification: Tauri 데스크톱 앱 전환
**Feature Branch**: `004-tauri-shell`
**Created**: 2026-08-11
**Status**: Draft
**Input**: User description: "이 프로젝트를 Rust + Tauri 버전으로 정의하고 수정을 시작한다. Tauri를 기본 데스크톱 앱으로 삼고, 기존 3_windowsApp(.NET WPF + WebView2)은 참고용 legacy로 남긴다."
## 이 기능이 뭔지 한 줄
기존 React 화면과 사용자 데이터를 그대로 살리면서, **Rust + Tauri를 CodeAssist의 기준 Windows 데스크톱 앱으로 완성한다.**
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - 프론트 한 벌로 Tauri와 브라우저에서 뜬다 (Priority: P1)
지금 화면(챗봇·스니펫 팔레트)은 데스크톱 껍데기가 넣어준 통로로 바깥과 얘기한다. 이 통로를 실행 환경에 맞게 고르면 화면 코드를 갈라 만들지 않고도 Tauri 앱과 브라우저 개발 화면을 함께 유지할 수 있다.
기존 WebView2 통로도 참고용 legacy 앱이 실행될 수 있을 만큼 남기되, 앞으로의 기본 실행·배포 기준은 Tauri다.
**Why this priority**: 화면과 Tauri 사이 통로가 먼저 열려야 창 제어·붙여넣기·캡쳐·스니펫을 차례로 연결할 수 있다. 화면을 한 벌로 유지하면 이후 기능도 같은 UI와 계약을 그대로 쓴다.
**Independent Test**: Tauri에서 현재 화면이 뜨고 데스크톱 동작이 Tauri 통로로 전달되는지 확인한다. 같은 화면을 브라우저로 열었을 때 데스크톱 기능만 빠지고 화면은 깨지지 않으면 통과다.
**Acceptance Scenarios**:
1. **Given** Tauri 앱을 실행했고, **When** 챗봇이나 스니펫 화면을 열면, **Then** 기존 React 화면이 그대로 뜨고 데스크톱 동작은 Tauri로 전달된다
2. **Given** 데스크톱 껍데기 없이 브라우저에서 화면만 열었고, **When** 데스크톱 전용 버튼이 있는 곳에 가면, **Then** 그 버튼은 안 보이거나 눌러도 아무 일 없고 화면은 안 깨진다
3. **Given** 참고용 legacy 앱을 실행했고, **When** 기존 기능을 사용하면, **Then** 별도 화면 코드 없이 남겨둔 WebView2 통로를 사용한다
4. **Given** 화면 코드를 쓰는 쪽(버튼·페이지 등), **When** 이번 변경 내역을 보면, **Then** 통로를 다루는 파일 바깥은 손댄 데가 없다
---
### User Story 2 - Tauri 앱을 단축키와 트레이로 쓴다 (Priority: P2)
CodeAssist를 켜두면 트레이에 상주한다. 사용자는 기존에 익숙한 전역 단축키로 챗봇·스니펫·캡쳐를 바로 부르고, 챗봇 단축키를 다시 누르면 창을 숨긴다.
창을 옮기고 크기를 바꾸거나 항상 위에 고정한 상태는 앱을 다시 켜도 복원된다. 앱을 두 번 실행해도 창은 하나만 유지된다.
**Why this priority**: CodeAssist의 기본 사용 흐름은 다른 프로그램에서 일하다 단축키로 필요한 도구를 잠깐 부르는 것이다. 이 흐름이 먼저 안정돼야 붙여넣기와 캡쳐를 실제 업무처럼 검증할 수 있다.
**Independent Test**: Tauri 앱 하나를 켜고 `Ctrl+Shift+7/8/9`를 차례로 눌러 각 기능이 열리는지, 챗봇 토글·트레이·창 상태 복원·단일 실행이 동작하는지 확인한다.
**Acceptance Scenarios**:
1. **Given** 앱이 실행 중이고, **When** `Ctrl+Shift+7`을 누르면, **Then** 스니펫 팔레트가 열린다
2. **Given** 앱이 실행 중이고, **When** `Ctrl+Shift+8`을 누르면, **Then** 챗봇 창이 열리고 다시 누르면 숨는다
3. **Given** 앱이 실행 중이고, **When** `Ctrl+Shift+9`를 누르면, **Then** 화면 캡쳐가 시작된다
4. **Given** 창 위치·크기·항상 위 상태를 바꾸고 앱을 다시 켜면, **Then** 마지막 상태가 복원된다
5. **Given** 앱이 이미 실행 중이고, **When** 앱을 한 번 더 실행하면, **Then** 새 창을 만들지 않고 기존 창을 불러낸다
6. **Given** 단축키가 다른 프로그램에 이미 먹혀서 등록에 실패했을 때, **When** 앱을 켜면, **Then** 앱이 죽지 않고 트레이로 창을 부를 수 있으며 무엇이 실패했는지 알 수 있다
---
### User Story 3 - 코드가 직전 앱에 붙는다 (Priority: P3)
작업하다 단축키로 CodeAssist를 불러 코드를 고르면, 창이 닫히면서 **아까 쓰던 그 앱**에 코드가 그대로 들어간다.
붙이기가 실패해도 코드는 클립보드에 남아서 사용자가 직접 붙여넣을 수 있다.
**Why this priority**: 스니펫과 챗봇의 코드 결과를 다시 복사하러 다니지 않는 것이 데스크톱 앱의 핵심 가치다. 포커스 전환이 느린 프로그램에서도 틀린 창에 붙지 않아야 한다.
**Independent Test**: 메모장·코드 에디터·느리게 뜨는 업무용 프로그램을 대상으로 각각 여러 번 붙여넣어 보고 결과와 대상을 확인한다.
**Acceptance Scenarios**:
1. **Given** 메모장에 커서를 두고, **When** 단축키로 창을 불러 코드를 고르면, **Then** 창이 닫히고 메모장에 그 코드가 들어간다
2. **Given** 창 전환이 느린 프로그램이 대상일 때, **When** 같은 동작을 하면, **Then** 코드가 엉뚱한 곳에 들어가거나 절반만 들어가는 일이 없다
3. **Given** 대상 앱이 붙여넣기를 거부하는 상황일 때, **Then** 코드는 클립보드에 남아 있어 사용자가 직접 붙일 수 있다
4. **Given** 창을 불러낸 상태에서, **Then** 어느 앱에 붙게 되는지 화면에 표시된다
---
### User Story 4 - 화면 일부를 잘라 챗에 붙인다 (Priority: P4)
단축키를 누르면 화면이 어두워지고, 마우스로 원하는 영역을 끌어서 고르면, 챗 입력창에 그 그림이 첨부된 채로 창이 뜬다. Esc로 취소하면 아무 일도 없다.
모니터가 여러 대이고 각 모니터 배율이 서로 달라도, **고른 영역과 잘린 그림이 일치**한다.
**Why this priority**: 화면에 보이는 오류·코드·업무 화면을 바로 대화에 붙이면 설명을 다시 타이핑할 필요가 없다. 좌표가 어긋나면 다른 내용을 보내게 되므로 정확도가 우선이다.
**Independent Test**: 배율이 서로 다른 모니터 두 대를 놓고, 각 모니터와 두 모니터에 걸친 영역을 여러 번 캡쳐해 고른 영역과 결과가 맞는지 눈으로 대조한다.
**Acceptance Scenarios**:
1. **Given** 배율 100% 모니터에서, **When** 영역을 끌어 고르면, **Then** 고른 영역과 똑같은 그림이 챗 입력창에 첨부된다
2. **Given** 배율이 다른 두 번째 모니터에서 같은 동작을 하면, **Then** 결과가 어긋나지 않는다
3. **Given** 영역 고르는 중에 Esc를 누르면, **Then** 아무것도 첨부되지 않고 화면이 원래대로 돌아간다
4. **Given** 영역을 거의 안 끌고 클릭만 하면, **Then** 캡쳐가 안 되고 조용히 취소된다
---
### User Story 5 - 기존 스니펫을 그대로 쓴다 (Priority: P5)
Tauri 앱으로 스니펫 팔레트를 처음 열어도 **기존 CodeAssist에서 쓰던 스니펫 목록이 그대로** 나온다. 새로 만들거나 고친 내용과 자주 쓰는 순서는 앱을 껐다 켜도 유지된다.
**Why this priority**: 이미 쌓아둔 코드 조각을 잃지 않아야 Tauri 앱을 바로 실제 업무에 쓸 수 있다. 데이터 이전을 따로 요구하지 않는 것이 전환의 핵심 조건이다.
**Independent Test**: 기존 스니펫 데이터가 있는 상태에서 Tauri 팔레트를 열어 목록을 확인하고, 하나를 수정·사용한 뒤 앱을 다시 켜서 변경과 사용 순서가 유지되는지 확인한다.
**Acceptance Scenarios**:
1. **Given** 기존 CodeAssist 스니펫이 저장돼 있고, **When** Tauri 앱에서 팔레트를 열면, **Then** 기존 목록이 그대로 보인다
2. **Given** Tauri 앱에서 스니펫을 만들거나 고쳤고, **When** 앱을 다시 켜면, **Then** 변경 내용이 유지된다
3. **Given** Tauri 앱에서 스니펫을 사용했고, **When** 목록을 다시 열면, **Then** 사용 횟수와 최근 사용 시각이 반영된 순서로 보인다
---
### Edge Cases
- 전역 단축키를 다른 프로그램이 이미 쓰고 있으면? → 앱은 계속 실행하고, 등록하지 못한 조합을 사용자에게 알린다
- 앱을 두 번 실행하면? → 두 번째 창을 새로 띄우지 않고 이미 떠 있는 창을 불러낸다
- 창이 화면 밖에 저장돼 있다가 모니터 구성이 바뀌면? → 화면 안 보이는 자리로 복원하지 않는다
- 아직 안 만든 기능을 화면이 부르면? → 조용히 성공한 척하지 않고 아직 안 됐다는 걸 사용자가 알 수 있게 한다
- 창이 준비되기 전에 껍데기가 화면에 알림을 보내려 하면? → 앱이 죽지 않고, 화면이 준비된 뒤 필요한 현재 상태를 읽을 수 있다
- 기존 스니펫 파일이 없으면? → 빈 목록으로 시작하고 새 스니펫을 정상 저장할 수 있다
- 참고용 legacy 앱을 Tauri와 동시에 켜면? → 같은 전역 단축키가 충돌할 수 있으며 동시 사용은 지원 범위가 아니다
---
## Requirements *(mandatory)*
### Functional Requirements
**통로 자동 감지 (US1)**
- **FR-001**: 화면은 실행 시점에 Tauri / 참고용 legacy / 껍데기 없음(브라우저) 중 어느 환경인지 스스로 판별해야 한다
- **FR-002**: 판별 결과에 따라 바깥과 얘기하는 방식을 자동으로 고르되, 화면을 쓰는 쪽 코드는 실행 환경을 몰라도 되어야 한다
- **FR-003**: 껍데기가 없으면 데스크톱 전용 동작은 조용히 넘어가고 화면은 정상 동작해야 한다
- **FR-004**: Tauri 앱과 브라우저 개발 화면은 같은 화면 결과물 하나를 써야 한다
- **FR-005**: Tauri가 화면에 보내는 알림(이동·붙여넣기 대상·캡쳐 결과)은 기존 계약 문서의 모양을 유지해야 한다
**데스크톱 앱 동작 (US2)**
- **FR-006**: Tauri 앱은 `Ctrl+Shift+7`=스니펫, `Ctrl+Shift+8`=챗봇, `Ctrl+Shift+9`=캡쳐 전역 단축키를 제공해야 한다
- **FR-007**: 창 위치·크기·항상 위 고정 상태를 저장하고 다음 실행에 복원해야 한다
- **FR-008**: 앱은 트레이에 상주하고, 열기 / 항상 위에 고정 / 종료를 제공해야 한다
- **FR-009**: 앱 인스턴스가 하나만 뜨도록 막아야 한다
- **FR-010**: 단축키 등록에 실패해도 앱은 계속 동작해야 하며, 무엇이 실패했는지 사용자가 알 수 있어야 한다
- **FR-011**: 챗봇 단축키는 토글로 동작해야 한다 — 보고 있으면 숨기고, 아니면 마지막 챗봇 자리로 데려온다
- **FR-012**: 창은 제목표시줄 없이 뜨고, 화면 안의 지정된 자리를 끌어서 창을 옮길 수 있어야 한다
**붙여넣기 (US3)**
- **FR-013**: 창을 부르기 직전에 쓰던 앱을 기억하고, 코드 선택 시 그 앱에 넣어야 한다
- **FR-014**: 붙여넣은 내용은 클립보드에 남겨 실패 시 사용자가 직접 붙일 수 있어야 한다
- **FR-015**: 어느 앱에 붙게 되는지 화면에 표시해야 한다
**화면 캡쳐 (US4)**
- **FR-016**: 단축키로 영역 선택 화면을 띄우고, 끌어서 고른 영역을 그림으로 만들어야 한다
- **FR-017**: 모니터가 여러 대이고 배율이 서로 달라도 고른 영역과 결과가 일치해야 한다
- **FR-018**: 취소(Esc·너무 작은 영역)는 아무 일도 일어나지 않아야 한다
- **FR-019**: 캡쳐한 그림은 새 대화 입력창에 첨부로 들어가야 한다
**스니펫 (US5)**
- **FR-020**: 기존 CodeAssist 스니펫 데이터를 별도 이전 작업 없이 그대로 읽고 써야 한다
- **FR-021**: 목록·생성·수정·삭제·사용기록이 기존 사용자 흐름과 동일하게 동작해야 한다
- **FR-022**: 저장 실패·중복 등의 오류는 사용자가 읽을 수 있는 한글 메시지로 화면에 떠야 한다
**완성 기준 (전 구간 공통)**
- **FR-023**: 아직 안 만든 기능은 **조용히 성공한 척하지 않고**, 아직 안 됐다는 걸 사용자가 알 수 있게 해야 한다
- **FR-024**: US1~US5를 모두 끝내 평소 업무 흐름 중간에 미구현으로 막히는 곳이 없어야 한다
- **FR-025**: 기본 실행·배포·사용 문서는 Tauri 앱을 기준으로 해야 한다
- **FR-026**: 기존 .NET 앱은 참고용 legacy로 남기되 새 기능·기본 배포·완료 검증 대상에서 제외해야 한다
- **FR-027**: 설치물과 실행 창에는 실험판 표기 없이 제품명 `CodeAssist`를 보여줘야 한다
### Key Entities
- **브릿지 메시지**: 화면과 Tauri가 주고받는 약속. 방향(화면→껍데기 / 껍데기→화면), 종류, 딸린 값. 기존 계약 문서가 원본
- **창 상태**: 창의 위치·크기·항상 위 고정 여부. 다음 실행에 복원할 값
- **스니펫**: 이름(고유)·설명·본문·분류 + 사용 횟수·마지막 사용 시각. 기존 CodeAssist 데이터를 이어서 사용
- **붙여넣기 대상**: 창을 부르기 직전 쓰던 앱의 제목과 프로그램 이름. 소환 시점에 한 번 찍어 보관
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 화면 결과물 한 벌이 Tauri 앱과 브라우저에서 모두 뜨며, 통로를 다루는 파일 바깥의 화면 코드 변경은 0줄이다
- **SC-002**: 프론트 자동 검사와 데스크톱 순수 로직 검사가 전부 통과한다
- **SC-003**: `Ctrl+Shift+7/8/9` 세 단축키가 각각 스니펫·챗봇·캡쳐만 실행하고, 반복 사용 중 무반응이나 오작동이 없다
- **SC-004**: 사용자가 Tauri 앱으로 단축키 소환 → 코드 선택 → 직전 앱 붙여넣기 흐름을 처음부터 끝까지 막힘없이 마친다
- **SC-005**: 사용자가 캡쳐 흐름을 끝까지 마치고, 배율이 다른 실제 모니터 각각과 두 모니터에 걸친 영역에서 고른 영역과 결과가 일치한다
- **SC-006**: 기존 스니펫의 목록·수정·사용기록이 Tauri 앱 재실행 뒤에도 유지된다
- **SC-007**: Tauri release 설치물이 만들어지고, 설치물과 실행 창에 제품명 `CodeAssist`가 표시된다
- **SC-008**: 기본 실행·배포·검증 명령에서 기존 .NET 앱을 요구하지 않는다
## Assumptions
- **Tauri가 기준 앱이다** — 앞으로 기본 실행·배포·새 데스크톱 기능은 Tauri를 대상으로 한다
- **기존 .NET 앱은 참고용 legacy다** — 코드는 남기지만 새 기능·배포·회귀 보장 대상은 아니다
- **기존 계약 문서가 원본이다** — 브릿지 메시지 약속은 `specs/001-snippet-palette/contracts/bridge-messages.md``specs/002-capture-to-chat/contracts/bridge-capture.md`를 이어서 쓴다
- **표준 단축키는 Tauri가 쓴다** — `Ctrl+Shift+7/8/9`를 Tauri에 배정하며 legacy 앱과 동시 실행은 지원하지 않는다
- **기존 스니펫 데이터는 보존한다** — `%LocalAppData%\CodeAssist\snippets.db`를 그대로 이어서 쓴다
- **Rust 개발 환경은 준비됐다** — rustc/cargo와 Windows 빌드 도구 설치·링크 확인이 끝난 상태다
- **미완성 스캐폴드는 이어서 쓴다** — 생성기 산출물은 유지하고, 손으로 쓴 Rust 소스는 컴파일하며 다시 확인한다
- **이 PC 한 대, 사용자 한 명이 우선 대상이다** — 여러 기기·여러 사용자 대상 검증은 범위 밖이다
- **자동 계측 장치는 만들지 않는다** — 실제 동작 검증에 필요한 최소 코드만 만든다
- **US1~US5는 모두 필수다** — 비교 결과에 따라 중간에 멈추는 경로는 없다
## Dependencies
- 기존 브릿지 계약 문서 2개
- 화면(`2_frontend`)의 통로 파일들
- 기존 CodeAssist 스니펫 데이터 파일
- 설치가 끝난 Rust·Windows 빌드 환경
## 정해진 것
### D1: Tauri를 기준 데스크톱 앱으로 삼는다 (2026-09-09)
- 기본 실행·배포·새 기능 대상은 `4_rust_tauri`
- 제품명에서 실험판 표기를 뺀다
- Tauri가 기존 표준 단축키 `Ctrl+Shift+7/8/9`를 사용한다
- `3_windowsApp`은 삭제하지 않고 참고용 legacy로 남긴다
- legacy 앱과 Tauri 앱의 동시 실행은 지원하지 않는다
### D2: 기능을 끝까지 옮긴다 (2026-09-09)
- US1~US5를 모두 완료한다
- A/B 비교·설치물 비교·판정 기록은 범위에서 뺀다
- “비교하다 결론이 나면 중간에 멈춤” 규칙을 없앤다
- 아직 안 만든 기능은 구현 중에만 분명한 오류로 알리고, 완료 시점에는 남아 있으면 안 된다
+33
View File
@@ -0,0 +1,33 @@
# Stage 1: 프론트 transport 전환
## 한 일
- `transport.ts` 한 곳에서 Tauri → WebView2 → 브라우저 순서로 실행 호스트를 고르게 함
- Tauri `invoke`/`listen`, legacy `postMessage`/`message`, 브라우저 no-op/reject를 같은 API로 묶음
- `route.changed` 예외와 `snippets.recordUse` 같은 camelCase command 이름 변환을 반영함
- `webviewBridge.ts`·`snippetBridge.ts`·`bridgeNavigate.ts`의 기존 export는 유지하고 내부 통로만 교체함
- React StrictMode 재초기화와 테스트별 새 WebView2 인스턴스에서 listener가 중복되거나 빠지지 않게 함
- 전체 lint를 막던 기존 파일 업로드 접근성 문제와 skill-mapping의 `any` 타입을 최소 수정함
## 검증 결과
- transport·navigate·snippet API 대상 검사: 4파일 20개 통과
- listener 재초기화 회귀 검사: 3파일 11개 통과
- `npm run format`: 통과
- `npm run lint`: 오류 0개, 기존 경고 9개
- `npm run test`: 43파일 244개 통과
- `npm run build`: TypeScript와 Vite production build 통과
- 브라우저 실제 확인:
- `/snap` 챗봇 화면과 `/snippet` 화면 렌더 확인
- `isWebView()``false`라 데스크톱 전용 붙여넣기 버튼 조건이 꺼짐
- `snippets.list` 요청이 `desktop-only: 데스크톱 전용 기능임`으로 reject됨
- page error와 unhandled rejection 없음
- Tauri 실제 확인:
- `CodeAssist` native 창과 React 로그인 화면 렌더 확인
- CDP로 붙은 실제 WebView에서 host가 `tauri`임을 확인하고 `transport.request("window.hide")`로 native 창이 숨는 것까지 확인
- native hotkey가 보낸 `bridge` 이벤트를 실제 listen해 `{ type: "navigate", path: "/snippet" }` 수신 확인
## 다음
- Phase 4 T026부터 표준 단축키·트레이·창 상태·단일 인스턴스를 Tauri 기준으로 확정함
- 현재 Rust 쪽의 비교용 `Ctrl+Alt+7/8/9`와 오래된 v0 문구를 `Ctrl+Shift+7/8/9` 기준으로 바로 걷어냄
+27
View File
@@ -0,0 +1,27 @@
# Stage 2: Tauri shell 기준 확정
## 한 일
- 표준 전역 단축키를 `Ctrl+Shift+7/8/9`로 바꿈
- 챗봇 토글과 마지막 챗봇 route 갱신 규칙을 순수 로직으로 분리함
- window-state는 `SIZE | POSITION`만 저장하게 제한함
- 단일 인스턴스, 트레이, 핀 파일 저장·복원, 창 show/hide/focus/drag 배선을 다시 확인함
- `window_hide`·`window_drag`·`report_route`를 실제 shell 함수에 연결함
- 아직 다음 단계인 붙여넣기·스니펫 command는 빈 성공값 대신 `아직 안 됨`으로 reject하게 정리함
## 검증 결과
- `cargo fmt --check`·`cargo clippy -- -D warnings`: 통과
- `cargo test`: 챗봇 토글·마지막 route 규칙 2개 통과
- `CodeAssist` 프레임리스 native 창과 React 화면 렌더 확인
- 헤더를 끌어 창 위치가 `(260, 110)`에서 `(380, 192)`로 이동하는 걸 확인
- `Ctrl+Shift+7`: `/snippet` 이동 신호 확인
- `Ctrl+Shift+8`: 보이는 챗봇 창 숨김과 숨은 창 재소환 확인
- `Ctrl+Shift+9`: 다른 route 동작 없이 capture 분기만 실행함. 실제 캡처는 US4에서 연결함
- 실행 파일을 다시 실행했을 때 새 창 대신 기존 창이 바로 소환되고 두 번째 프로세스는 종료됨
- 창을 `(100, 120)`, `820×620`으로 바꾸고 핀 파일을 `true`로 둔 뒤 재실행해 위치·크기 복원과 Win32 `topmost=true`를 확인함. 검증 뒤 원래 `(420, 200)`, `976×689`, 핀 `false`로 돌려둠
- 트레이 아이콘 생성과 열기/핀/종료 메뉴 배선은 실제 빌드에서 초기화됨. 메뉴 항목 클릭 자체는 자동 UI 트리로 구분되지 않아 코드·실행 초기화까지만 확인함
## 다음
- Phase 5 T041부터 기존 `%LocalAppData%\CodeAssist\snippets.db`를 Rust에서 그대로 열어 스니펫 CRUD와 붙여넣기를 완성함
+18
View File
@@ -0,0 +1,18 @@
# Stage 4: Tauri 화면 캡쳐 구현
## 한 일
- `Ctrl+Shift+9` 스텁을 Win32 네이티브 캡쳐 오버레이에 연결함.
- 전체 가상 화면을 반투명·항상위 창으로 덮고, 드래그 영역을 `BitBlt`로 캡쳐해 PNG data URL로 만듦.
- 캡쳐 뒤 `/snap/new`로 이동하고 기존 `capture.image` bridge 계약으로 Composer에 첨부함.
- Esc·포커스 이탈·4px 미만 선택은 조용히 취소하고, 중복 핫키는 무시함.
## 검증 결과
- `cargo test --locked`: 11 passed, 1 ignored.
- 역방향 드래그·음수 좌표 모니터·작은 선택 취소 단위 테스트 통과.
- 실제 픽셀 일치, 100%/150% 혼합 배율, 모니터 사이 드래그는 T054 수동 확인이 남음.
## 다음
- quickstart D로 실기기 캡쳐·Esc·작은 클릭·혼합 DPI를 확인함.
+21
View File
@@ -0,0 +1,21 @@
# 단계 5: 스니펫 저장
## 한 일
- 기존 `%LocalAppData%\CodeAssist\snippets.db` 스키마 그대로 생성·수정·삭제·사용기록 저장을 연결함.
- 이름 정규화, 중복 이름, 빈 필수값, 없는 스니펫 수정 오류를 legacy와 같은 한글 메시지로 맞춤.
- 삭제는 스니펫과 usage를 transaction 하나로 함께 지움.
- DB가 없으면 기존 snippets/usage 스키마로 새로 만들고 폴더도 준비함.
## 검증
- RED: 저장 함수가 없는 상태에서 신규 Rust 테스트가 컴파일 오류 12개로 실패함.
- GREEN: 임시 메모리·파일 DB에서 생성→재오픈 조회→수정→사용횟수 2회 증가→삭제와 usage 정리 통과.
- Rust: fmt, clippy `-D warnings`, 전체 검사 9개 통과·로컬 실DB 읽기 검사 1개 제외.
- 프론트: 50파일 273개 검사, lint 오류 0개·기존 경고 9개, build 통과.
- 실행 중 Tauri 창의 스니펫 route 진입은 확인함. 자동조작 도구가 WebView 입력 focus를 못 잡아 실제 UI 생성·수정·삭제 한 바퀴는 확인하지 못함.
- 생성 성공 시 입력창을 바로 닫지 않고 성공 toast를 1초 보여준 뒤 검색어·분류·선택을 비우고 기본 검색 화면으로 돌아가게 함. 프론트 전체 50파일 274개 검사와 build 통과.
## 다음
- T062 실제 UI에서 테스트 항목 하나를 생성→수정→삭제하고 재실행 뒤 상태를 확인함.
@@ -0,0 +1,31 @@
# main 기준 Tauri 작업 통합
## 통합한 작업
- main의 클린 블루·`/snap` 시작·스니펫 SQLite 읽기·Enter 복사와 canonical의 공통 transport·직전 앱 붙여넣기를 함께 보존.
- `rusqlite``windows` 의존성을 모두 유지하고 Rust command에 두 구현을 연결.
- Tauri 제품명은 `CodeAssist`, identifier는 canonical의 `com.codeassist.app` 사용. 기존 `com.codeassist.tauri`의 창 위치·핀 설정을 자동 이관하는 작업은 포함하지 않음. 스니펫 DB 경로는 기존 `%LocalAppData%\CodeAssist\snippets.db` 유지.
- UI의 호스트 판별을 `transport.hostKind()`로 통일하고 main의 오류 토스트를 공통 transport로 이관.
- 이전 양쪽 이력과 작업로그를 보존. Tauri 기준 명세를 따르며 A/B 실험용 절차는 제거.
## 검증
- 프론트 전체 45파일·255개 테스트 통과. `npm run lint` 오류 0개·기존 경고 9개, `npm run build` 통과.
- `cargo fmt --check`, `cargo clippy --locked -- -D warnings` 통과. `cargo test --locked` 4개 통과·로컬 DB 검사 1개는 기본 제외.
- 제외된 로컬 DB 읽기 검사도 별도로 실행해 기존 스니펫 137개 조회 통과. 읽기 전용으로 열어 원본 데이터는 수정하지 않음.
- 실제 대상 앱에 붙여넣기·전역 단축키·트레이 조작은 이번 통합에서 재검증하지 않음.
## 남은 작업
- Phase 5의 실제 대상 앱 붙여넣기 검증, Phase 6 캡처, Phase 7 스니펫 쓰기 등은 미완료 상태를 유지.
- 통합 시점의 Raycast UI는 초안이었음. 이후 승인된 단계별 스니펫 개선 결과는 아래와 같음.
## 후속: 단계별 스니펫 런처 (2026-09-11)
- 검색창만 시작 → 검색 결과 → 직접 선택 시 코드 프리뷰. Enter 원문 복사와 기존 DB 읽기 유지.
- 실제 Tauri 콘텐츠 크기 640×84 → 640×440 → 960×600. DPI·작업 영역과 Windows 바깥 테두리를 반영.
- 이전 배치를 메모리에 보관하고 챗봇 복귀·종료 시 복원. 최대화 전 일반 배치도 유지.
- 축소 상태에서 native close 후 작은 크기가 저장되는 문제를 재현. 복원 직후 window-state 캐시에 실측값을 저장하도록 고쳐 재시작 시 960×680 확인.
- 실제 앱에서 SELECT 검색 결과 7개·화살표 선택·프리뷰·Esc 축소·빈 결과·편집/삭제 확인 취소·재소환·우하단 경계 확인. 원본 DB는 변경하지 않음.
- 프론트 46파일·259검사, lint 오류 0·기존 경고 9, build 통과. Rust 6검사 통과·로컬 DB 전용 1검사 기본 제외, fmt와 clippy `-D warnings` 통과.
- 생성·수정·삭제·사용기록의 Rust 미구현은 그대로 남김. 이번 개선 완료와 전체 스니펫 기능 완료는 구분함.
+297
View File
@@ -0,0 +1,297 @@
# Tasks: Tauri 기준 데스크톱 앱 전환
**Input**: `specs/004-tauri-shell/` 의 설계 문서 (spec.md, plan.md, research.md, data-model.md, contracts/, quickstart.md)
**Tests**: 포함한다. CLAUDE.md §1 이 TDD(RED→GREEN)를 파이프라인에 박아뒀고, research R12 가 "자동으로 될 것만 자동으로" 기준으로 대상을 이미 갈라놨다.
**Organization**: 유저스토리 우선순위대로 쌓는다. US1~US5는 전부 필수이며 중간 중단 경로는 없다.
## Format: `[ID] [P?] [Story] 설명`
- **[P]**: 병렬 가능 (파일 서로 안 겹치고 선행 task 에 안 물림)
- **[Story]**: 어느 유저스토리 소속인지 (US1~US5)
- 파일 경로는 repo 루트 기준
---
## Phase 1: Setup (환경·선행 문서)
**목적**: 첫 Rust 코드보다 먼저 있어야 하는 것들. US1 은 T004 만 있으면 시작 가능.
- [x] T001 rust 툴체인 설치·확인 — `rustup` + MSVC 타깃(`x86_64-pc-windows-msvc`) + VS Build Tools/Windows SDK. `cargo --version`·`rustc --version`·`cl.exe` 존재가 통과 조건 (research R1, quickstart §사전준비)
**2026-08-15 완료.** rustc/cargo 1.97.1, 기본 툴체인 `stable-x86_64-pc-windows-msvc`. 별도 Build Tools 를 새로 받지 않고 **이미 있던 VS 2026 Community 에 컴포넌트만 추가**(`VC.Tools.x86.x64` + `Windows11SDK.26100`) — MSVC 14.51.36231, SDK 10.0.26100.0. `rustc` 로 실제 링크까지 통과 확인
- [x] T002 [P] `4_rust_tauri/docs-lib/README.md` 신규 — 카탈로그 (CLAUDE.md §3 양식)
- [x] T003 [P] `4_rust_tauri/docs-lib/tauri-v2.md` 신규 — 실제 쓸 API 표면 박제: 플러그인 4종 init(`global-shortcut`/`single-instance`/`window-state`/`opener`), `TrayIconBuilder`, `Menu`/`CheckMenuItem`, `WebviewWindow`(show/hide/set_focus/set_always_on_top/start_dragging/is_visible), `#[tauri::command]`+`State`, `app.emit`, `app.path().app_local_data_dir()`, capabilities 권한 이름. **R11 의 미확정(Tauri v2 에서 PerMonitorV2 DPI 인지를 어디서 켜는가)도 여기서 확인해 적는다** (research R3)
**2026-08-15 완료.** 공식 가이드 예제를 안 믿고 `cargo fetch` 로 받은 **실제 crate 소스**에서 뽑음(가이드 안에 2인자/3인자 예제가 둘 다 있어서 근거로 못 씀). 버전 박제: tauri 2.11.5 / global-shortcut 2.3.2 / single-instance 2.4.3 / window-state 2.4.1 / tao 0.35.3. 원문은 `tauri-v2-llms-full.md`(2.4MB) 로 같이 받아둠
- [x] T004 [P] `2_frontend/docs-lib/tauri-api.md` 신규 — 프론트가 쓸 `invoke`/`listen` 표면만 박제 (+ `2_frontend/docs-lib/README.md` 카탈로그에 한 줄 추가)
**2026-08-15 완료.** `node_modules/@tauri-apps/api/` 의 실제 `.d.ts`·`.js` 에서 뽑음 (2.11.1)
- [x] T005 `2_frontend/package.json``@tauri-apps/api` v2 추가 후 `npm install` — 이 기능의 유일한 신규 프론트 의존성 (plan Complexity Tracking 1번)
**2026-08-15 완료.** `^2.11.1`
**Checkpoint**: T003 없이 Rust 코드를 쓰지 않는다. T005 없이 US1 을 시작하지 않는다.
### 📌 Phase 1 이 뒤 단계에 남긴 것 (docs-lib 만들며 새로 확인된 사실)
문서 만들다 **계획을 바꿔야 하는 것 3건**이 나왔다. 해당 task 본문에도 반영해뒀다.
| 무엇 | 어디에 영향 | 요약 |
|---|---|---|
| **DPI 인지는 이미 켜져 있다** | T049 | `tao` 가 이벤트 루프 만들 때 `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` 를 무조건 부름. R11 의 "매니페스트 넣기"는 **할 일이 없어졌다** — 확인만 하고 넘어감 |
| **호스트 판별식을 바꿔야 한다** | T012·T015 | `"__TAURI_INTERNALS__" in window` 는 틀린다. 공식 `clearMocks()`**속성만 지우고 빈 객체를 남겨서** 정리 후에도 계속 true → 브라우저 모드 테스트가 tauri 로 오판됨. `typeof window.__TAURI_INTERNALS__?.invoke === "function"` 으로 잰다 |
| **window-state 기본 플래그가 함정** | T030 | `StateFlags` 기본값이 `all()` 이라 `VISIBLE`·`DECORATIONS` 까지 저장·복원한다. 트레이 상주 + 프레임리스 앱엔 안 맞음 → `SIZE \| POSITION` 만 켠다 |
**공식 테스트 mock 이 있다는 것도 알아냈다**`@tauri-apps/api/mocks``mockIPC`/`clearMocks`, `shouldMockEvents: true``listen`/`emit` 까지 mock 됨. T012~T014 는 이걸 쓴다 (직접 흉내낼 필요 없음).
---
## Phase 2: Foundational (Tauri 빌드 베이스라인)
**목적**: `4_rust_tauri`가 컴파일되고 현재 `2_frontend` 화면을 띄우는 기준 앱 베이스라인 만들기.
**범위 주의**: 여기서 제품명·identifier·빌드 경로를 기준 앱 값으로 확정한다. 손으로 쓴 Rust와 설정은 컴파일 검증 0줄이므로 전부 다시 본다.
**⚠️ 전제**: 손으로 쓴 `src-tauri/src/**/*.rs`·`Cargo.toml`·`tauri.conf.json` 은 **컴파일 검증 0줄**이다. "있으니 맞겠지"로 넘기지 말고 전부 다시 본다 (research R2).
- [x] T006 `4_rust_tauri/src-tauri/src/main.rs`의 lib 이름이 `codeassist_tauri_lib::run()`으로 `[lib] name`과 일치하는지 확인
- [x] T007 `4_rust_tauri/src-tauri/Cargo.toml` 재검증 — Tauri 2의 `tray-icon`·`image-png`, global-shortcut·single-instance·window-state, serde·serde_json만 포함. `rusqlite`·`windows`는 필요한 스토리까지 미룸
- [x] T008 `4_rust_tauri/src-tauri/tauri.conf.json``productName`/창 제목=`CodeAssist`, `identifier`=`com.codeassist.app`, devUrl·frontendDist·프레임리스 설정 확인
- [x] T009 `4_rust_tauri/src-tauri/capabilities/default.json` — 프론트가 우리 command만 invoke하므로 `core:default`면 listen/emit까지 충분함을 docs-lib 기준으로 확인
- [x] T010 `4_rust_tauri/package.json` — Tauri CLI 스크립트만 있고 프론트 dev/build는 `tauri.conf.json``2_frontend`에서 실행함을 확인
- [x] T011 `cd 4_rust_tauri && npm run tauri dev` 첫 통과 — Rust dev 빌드 완료, 제목 `CodeAssist` 창에서 `2_frontend` 로그인 화면 렌더 확인
**Checkpoint**: 여기까지 되면 실제 Tauri bridge와 데스크톱 기능을 연결할 수 있다.
---
## Phase 3: User Story 1 - 프론트 한 벌로 Tauri와 브라우저에서 뜬다 (Priority: P1) 🎯 MVP
**Goal**: `2_frontend`가 실행 시점에 Tauri·legacy·브라우저를 판별해 맞는 통로를 고른다. 화면 소비자는 바꾸지 않는다.
**Independent Test**: Tauri 앱에서 실제 invoke/listen 왕복이 되고, 브라우저에서는 화면이 깨지지 않으며, legacy adapter 자동 검사가 통과하면 완료.
**위험**: 기존 브릿지 테스트의 WebView2 mock을 transport mock으로 바꾸면서 실제 Tauri 경로가 아니라 mock끼리만 맞을 수 있다. 자동 검사 뒤 실제 Tauri 앱을 반드시 실행한다.
### Tests for User Story 1 ⚠️ 먼저 쓰고 RED 확인
> transport는 사용할 때 호스트를 판별한다. 테스트 전용 reset API는 만들지 않는다. Windows Tauri도 WebView2를 쓸 수 있으므로 판별 순서는 `tauri` → `webview2` → `browser`다.
- [x] T012 [P] [US1] `2_frontend/src/lib/bridge/transport.test.ts` 신규 — 호스트 3종 판별(`tauri``webview2``browser`), 브라우저 단방향 no-op·응답 요청 즉시 reject를 먼저 작성하고 RED 확인. Tauri는 `@tauri-apps/api/mocks``mockIPC`/`clearMocks` 사용
- [x] T013 [P] [US1] `2_frontend/src/lib/bridge/bridgeNavigate.test.ts``chrome.webview` mock을 transport 경유 mock으로 교체. 기존 분기·CustomEvent 기대값은 유지
- [x] T014 [P] [US1] `2_frontend/src/lib/bridge/snippetBridge.test.ts``2_frontend/src/features/snippets/api/snippets.api.test.ts`의 mock을 transport 경유로 교체. 소비자 소스는 무변경
### Implementation for User Story 1
- [x] T015 [US1] `2_frontend/src/lib/bridge/transport.ts` 신규 — `HostKind` 판별 + 전송수단 3종(tauri: `invoke`+`listen`, webview2: `postMessage`+`addEventListener`, browser: no-op·reject). Tauri 판별식은 `typeof window.__TAURI_INTERNALS__?.invoke === "function"`이며 호출 시점에 판별
- [x] T016 [US1] `2_frontend/src/lib/bridge/webviewBridge.ts` 를 transport 위로 얇게 — `isWebView`/`hideWindow`/`pasteToApp`/`reportRoute`/`startWindowDrag` **export 시그니처 불변**, 속만 transport 호출로 교체 (`isWebView` 는 "데스크톱 껍데기 안인가"로 의미 확장)
- [x] T017 [US1] `2_frontend/src/lib/bridge/snippetBridge.ts` 축소 — `reqSeq`/`pending` Map/`ensureListener`**지우지 말고 `transport.ts` 의 webview2 구현 안으로 옮긴다**(기존 껍데기 경로엔 그대로 필요). 여기엔 `request(type, payload)` 만 남김 (research R6)
- [x] T018 [US1] `2_frontend/src/lib/bridge/bridgeNavigate.ts` — 리스너 부착만 transport 로 교체. `paste.target`/`capture.image`/`navigate` 분기와 `CustomEvent` dispatch **본문은 한 줄도 안 바꾼다** (research R5 — 이게 diff 를 가장 크게 줄이는 한 수).
**transport 가 흡수해야 하는 차이 2개** (`docs-lib/tauri-api.md` §2): ① Tauri 는 페이로드가 `Event<T>` 로 한 겹 싸여 오므로 **`e.payload` 를 벗겨서** 넘겨야 분기 본문이 그대로 산다 ② `listen``Promise<UnlistenFn>` 이라 **async**`initBridgeNavigate()` 를 async 로 바꾸면 호출부가 바뀌어 SC-001 위반이므로 transport 안에서 Promise 를 삼키고 밖은 동기 유지. 붙기 전 도착분은 기존 `lastPasteTarget` 스냅샷·`pendingCaptureImage` read-once 가 이미 막아줌
- [x] T019 [US1] `transport.ts` 에 타입명→커맨드명 매핑 반영 — `snippets.recordUse``snippets_record_use` 기계 변환 + **예외 1건** `route.changed``report_route` (contracts/transport-mapping.md §1)
- [x] T020 [US1] 미구현 응답 규칙 — tauri 경로에서 `invoke` reject 는 한글 메시지 그대로 `Error` 로 올라가 react-query `onError`→sonner 로 흐르는지 확인. 빈 값(`[]`,`null`)으로 성공을 흉내내지 않는다 (FR-023, transport-mapping §3)
### 검증 for User Story 1
- [x] T021 [US1] 소비자 diff 0줄 확인 — `2_frontend/src/lib/bridge/`와 관련 테스트 바깥의 화면 소비자 소스에 변경이 없는지 확인
- [x] T022 [US1] 자동 검사 — `cd 2_frontend && npm run format && npm run lint && npm run test && npm run build` 전부 통과
- [x] T023 [US1] 브라우저 — `npm run dev` 후 챗봇 화면, 콘솔 오류 0, 데스크톱 전용 붙여넣기 버튼 숨김, `/snippet`의 "데스크톱 전용" 오류 확인
- [x] T024 [US1] 실제 Tauri 앱 — `npm run tauri dev`에서 화면→Rust `invoke`와 Rust→화면 `bridge` 이벤트를 각각 한 번 이상 실제 동작으로 확인
- [x] T025 [US1] `specs/004-tauri-shell/stage-1.md` 기록 — 한 일 / 검증 결과 / 다음
**Checkpoint**: Tauri와 브라우저가 같은 화면 한 벌을 쓰고 실제 bridge 왕복이 된다.
---
## Phase 4: User Story 2 - Tauri 앱을 단축키와 트레이로 쓴다 (Priority: P2)
**Goal**: Tauri 앱이 표준 단축키·트레이·창 상태 복원·단일 인스턴스를 제공한다.
**Independent Test**: Tauri 앱 하나에서 `Ctrl+Shift+7/8/9`, 챗봇 토글, 트레이 메뉴, 창 상태 복원, 두 번 실행을 확인한다.
**의존**: Phase 2 + Phase 3. 챗봇 토글이 `route.changed` 보고에 물린다.
### Tests for User Story 2
- [x] T026 [P] [US2] `4_rust_tauri/src-tauri/src/lib.rs` 의 챗봇 토글 규칙을 순수 함수로 뽑고 `cargo test` 작성 — "창 보이는 중 AND current 가 `/snippet` 아님 → 숨김, 그 외 → 띄우고 `lastSnap` 으로" + `lastSnap` 갱신 규칙(`/snippet` 으로 시작 안 하면 같이 갱신) (data-model §4). 창·트레이·핫키는 자동 테스트 대상 아님 (research R12)
### Implementation for User Story 2
- [x] T027 [P] [US2] `4_rust_tauri/src-tauri/src/shell/window.rs` 재검증 — show/hide/set_focus/set_always_on_top/start_dragging/is_visible. CodeAssist 를 하나도 모르는 층으로 유지 (plan Structure Decision)
- [x] T028 [P] [US2] `4_rust_tauri/src-tauri/src/shell/hotkey.rs` 재검증 — `Ctrl+Shift+7/8/9` 등록. 등록 실패해도 앱을 죽이지 않고 어느 조합이 실패했는지 알림
- [x] T029 [P] [US2] `4_rust_tauri/src-tauri/src/shell/tray.rs` 재검증 — 트레이 상주 + 열기/항상 위에 고정/종료
- [x] T030 [US2] `4_rust_tauri/src-tauri/src/lib.rs`에 window-state·single-instance 배선 — `.with_state_flags(StateFlags::SIZE | StateFlags::POSITION)`만 사용. 저장 경로는 `%LocalAppData%\com.codeassist.app\`
- [x] T031 [US2] 핀(항상 위) 상태만 별도 저장/복원 — `app_local_data_dir()` 아래 단일 파일. window-state 플러그인이 always-on-top 은 안 봄 (research R8)
- [x] T032 [US2] `4_rust_tauri/src-tauri/src/lib.rs` 조립 — 어느 핫키가 무엇을 하는지(7=스니펫, 8=챗봇 토글, 9=캡쳐)와 `Routes{current,lastSnap}` 상태를 **여기에만** 모은다. `shell::init(config)` 통합 진입점은 **만들지 않는다** (plan Structure Decision — 구현 하나짜리 추상화 금지)
- [x] T033 [US2] `4_rust_tauri/src-tauri/src/bridge/commands.rs``window_hide`/`window_drag`/`report_route` 구현 (transport-mapping §1)
- [x] T034 [US2] `4_rust_tauri/src-tauri/src/bridge/mod.rs` — 푸시를 **단일 이벤트 `"bridge"` 에 `{type, ...}` 그대로** 실어 `app.emit`. 우선 `navigate` 부터 (research R5, transport-mapping §2)
- [x] T035 [US2] 아직 안 만든 커맨드는 한글 "아직 안 됨" 으로 **reject** — 빈 값으로 성공 흉내 금지 (FR-023). 이 시점엔 `paste_code`·`snippets_*` 가 대상
- [x] T036 [US2] 창은 제목표시줄 없이 뜨고 헤더 끌기로 이동 — 프론트의 `window.drag``start_dragging` 까지 도달하는지 (FR-012)
### 검증 for User Story 2
- [x] T037 [US2] quickstart C 기본 앱 — `CodeAssist` 프레임리스 창, 현재 챗봇 화면, 트레이, 두 번 실행 시 기존 창 소환 확인
- [x] T038 [US2] quickstart C 표준 단축키 — `Ctrl+Shift+7`=스니펫, `Ctrl+Shift+8`=챗봇 토글, `Ctrl+Shift+9`=캡쳐만 반응하는지 확인
- [x] T039 [US2] quickstart C 창 상태 — 창 위치·크기·핀을 바꾸고 재실행해 복원되는지 확인
- [x] T040 [US2] `specs/004-tauri-shell/stage-2.md` 기록 — 한 일 / 검증 결과 / 다음
**Checkpoint**: CodeAssist를 Tauri 기준 앱으로 상시 켜두고 단축키로 부를 수 있다.
---
## Phase 5: User Story 3 - 코드가 직전 앱에 붙는다 (Priority: P3)
**Goal**: 단축키로 창 불러 코드 고르면 **아까 쓰던 그 앱**에 들어간다.
**Independent Test**: 메모장·VS Code·창전환 느린 앱 각각에 여러 번 붙여넣어 성공률 세기.
- [ ] T041 [US3] `4_rust_tauri/src-tauri/Cargo.toml``windows` crate 추가 (Win32 직접 호출 — 플러그인 안 씀, research R10)
- [ ] T042 [US3] `4_rust_tauri/src-tauri/src/shell/paste.rs` 신규/재검증 — **순서를 그대로 지킨다**: ① 클립보드 적재 → ② **우리 창 숨기기 전에** `SetForegroundWindow` → ③ 짧게 대기 → ④ `Ctrl+V` 주입 → ⑤ 클립보드 **원복 안 함**. 참조 원본은 `3_windowsApp/CodeAssist.Shell/Platform/PasteService.cs` 의 "함정 박제" 주석 (research R10)
- [ ] T043 [US3] 대기시간을 **조정 가능한 값**으로 둔다 — .NET 의 80ms 로 시작. 물리 타이밍이라 환경 따라 다름 (research R10 "미해결로 남기는 것")
- [ ] T044 [US3] 붙여넣기 대상 스냅샷 — 창 소환 **직전**에 창 제목·프로세스명을 **값으로 복사**해 보관(창이 나중에 닫혀도 표시는 남아야 함, data-model §3)
- [ ] T045 [US3] `bridge/mod.rs``{type:"paste.target", name, app}` 푸시 추가 → 프론트 배지에 직전 앱 이름 표시 (FR-015)
- [ ] T046 [US3] `bridge/commands.rs``paste_code` 구현 — 실패해도 **코드는 클립보드에 남긴다**(사용자가 직접 `Ctrl+V`, FR-014). T035 의 "아직 안 됨" reject 제거
- [ ] T047 [US3] quickstart C 수동 검증 — 메모장/VS Code/창전환 느린 업무용 앱 3종. **엉뚱한 곳에 붙거나 절반만 붙지 않아야 함**. 배지에 앱 이름 보이는지, 실패 시 클립보드에 남는지. 느린 앱에서 어긋나면 T043 값을 올려본다
- [ ] T048 [US3] `specs/004-tauri-shell/stage-3.md` 기록
**Checkpoint**: SC-004 도달 — 평소 흐름(소환→코드 고름→직전 앱에 붙음)을 처음부터 끝까지 해볼 수 있다.
**2026-09-11 통합 상태**: T041~T046 관련 구현은 main에 보존했고 Rust 컴파일·단위 검사를 통과함. T047의 실제 대상 앱 검증과 단계 인수는 아직 미완료. 스니펫 DB 읽기 구현도 main에서 가져왔지만 전체 CRUD 완료를 뜻하지 않음. 상세: `stage-main-integration.md`.
---
## Phase 6: User Story 4 - 화면 일부를 잘라 챗에 붙인다 (Priority: P4)
**Goal**: 단축키 → 영역 드래그 → 챗 입력창에 그림 첨부. **멀티모니터·다른 배율에서도 고른 영역과 결과가 일치.**
**Independent Test**: 배율 다른 모니터 두 대에서 각각·걸친 영역을 여러 번 캡쳐해 눈으로 대조.
- [x] T049 [US4] **PerMonitorV2 DPI 인지 — 확인만 하고 넘어간다.** ~~켜기~~ **할 일 없음이 T003 에서 확인됐다**: `tao` 0.35.3 이 이벤트 루프 생성 시(`event_loop.rs:190`) `become_dpi_aware()``SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)`**무조건** 부른다. .NET 판이 `app.manifest` 를 따로 넣어야 했던 것과 다름 (`4_rust_tauri/docs-lib/tauri-v2.md` §8).
**그래서 T054 에서 좌표가 어긋나면 원인이 DPI 설정이 아니다** — 논리↔물리 좌표 변환(T051) 쪽을 본다. R11 의 미확정 항목은 이걸로 닫힘
- [x] T050 [US4] `4_rust_tauri/src-tauri/src/shell/capture.rs` 신규/재검증 — 전체 가상 화면을 덮는 **투명·항상위 오버레이** 창 + 드래그 영역 선택
- [x] T051 [US4] 네이티브 오버레이의 물리 픽셀 좌표로 그 영역 잘라내기 — `windows` crate 의 `BitBlt` 직접 호출(.NET 판과 같은 API). Tauri의 PerMonitorV2 프로세스에서 Win32 가 주는 client/가상화면 좌표는 이미 물리 픽셀이라 별도 배율 곱을 하지 않음
- [x] T052 [US4] 취소 경로 — Esc, 포커스 이탈, 그리고 드래그 거의 없이 클릭만 하면 **조용히 취소**하고 화면 원복 (FR-018)
- [x] T053 [US4] `bridge/mod.rs` 의 기존 `{type:"capture.image", dataUrl}` 계약을 실제 캡쳐 완료 경로에 연결 — 프론트의 read-once 소비 경로(`consumePendingCaptureImage`)에 그대로 물림
- [ ] T054 [US4] quickstart D 수동 검증 — 배율 100%/150% 모니터·두 모니터에 걸친 영역·Esc·클릭만. 캡쳐물을 원본과 나란히 놓고 눈으로 대조(어긋나면 보통 일정 비율로 밀림) (SC-005)
- [x] T055 [US4] `specs/004-tauri-shell/stage-4.md` 기록
---
## Phase 7: User Story 5 - 기존 스니펫을 그대로 쓴다 (Priority: P5)
**Goal**: Tauri가 기존 CodeAssist 스니펫 DB를 그대로 읽고 쓴다.
**Independent Test**: 기존 데이터가 Tauri 팔레트에 보이고, 생성·수정·사용기록이 재실행 뒤에도 유지되면 통과.
**데이터 주의**: `%LocalAppData%\CodeAssist\snippets.db`의 스키마와 저널 모드를 바꾸지 않는다.
- [x] T056 [US5] `4_rust_tauri/src-tauri/Cargo.toml``rusqlite`(bundled feature) 추가
- [x] T057 [US5] 기존 `4_rust_tauri/src-tauri/src/bridge/snippets.rs` 확장 — `%LocalAppData%\CodeAssist\snippets.db`를 열고 없으면 기존 형식으로 생성. 참고 원본은 legacy `SnippetRepository.cs`
- [x] T058 [P] [US5] `cargo test` — 이름 중복과 빈 필수 필드 거부 테스트를 먼저 작성하고 RED 확인
- [x] T059 [US5] `bridge/commands.rs``snippets_list`/`snippets_create`/`snippets_update`/`snippets_delete`/`snippets_record_use` 구현
- [x] T060 [US5] 오류를 한글 메시지로 reject — 중복 이름·빈 필드·저장 실패가 프론트 `Error`→sonner로 흐르는지 확인
- [x] T061 [US5] `usageCount` +1 / `lastUsed` 갱신 — 재실행 뒤 자주 쓰는 순서에 반영
- [ ] T062 [US5] quickstart F — 기존 목록 표시 / 생성·수정 / 사용 횟수·순서 / 재실행 후 유지 / 빈 DB 첫 저장 확인
- [x] T063 [US5] `specs/004-tauri-shell/stage-5.md` 기록
**Checkpoint**: 기존 데이터를 잃지 않고 Tauri 앱을 평소 업무에 쓸 수 있다.
---
## Phase 8: Polish & 마무리 (기준 앱 전환)
**목적**: 미구현 경로를 없애고 제품 정의·문서·release 결과물을 Tauri 기준으로 맞춘다.
- [ ] T064 quickstart G 확인 — US1~US5 필수 요청 중 "아직 안 됨" reject가 남아 있지 않은지 실제 흐름으로 확인
- [ ] T065 [P] 제품 정의 갱신 — `CLAUDE.md`, `4_rust_tauri/README.md`, `4_rust_tauri/src-tauri/Cargo.toml`, `tauri.conf.json`에서 Tauri를 기준 앱, .NET을 참고용 legacy, 제품명을 `CodeAssist`로 통일
- [ ] T066 최종 검증 — `2_frontend`에서 format+lint+test+build, `4_rust_tauri`에서 cargo test + `npm run tauri build`. 기본 검증에 `3_windowsApp`을 요구하지 않음
- [ ] T067 `/code-review` + `superpowers:verification-before-completion`
- [ ] T068 `specs/004-tauri-shell/report.md` 작성 — 계획 대비 구현 결과, 자동 검사, Tauri 실제 실행, 붙여넣기·캡쳐·기존 데이터 검증 증거
- [ ] T069 사용자 최종 승인 → merge
---
## Dependencies & Execution Order
### Phase 의존
```
Phase 1 (Setup)
└── Phase 2 (Tauri 빌드 베이스라인)
└── Phase 3 (US1 transport)
└── Phase 4 (US2 창·단축키)
├── Phase 5 (US3 붙여넣기)
├── Phase 6 (US4 캡쳐)
└── Phase 7 (US5 스니펫)
└── Phase 8 (기준 앱 전환 마무리)
```
- **Phase 1**: 완료
- **Phase 2**: 모든 구현의 실행 베이스라인
- **Phase 3**: Phase 2 필요. 실제 Tauri bridge 왕복까지 확인
- **Phase 4**: Phase 3 필요. route 보고 위에 창 토글을 연결
- **Phase 5~7**: Phase 4 필요. 세 기능 모두 완료
- **Phase 8**: US1~US5 전부 끝난 뒤
### 스토리 간 의존
- **US1**: Tauri 빌드 베이스라인 필요
- **US2**: US1 필요
- **US3·US4·US5**: US2 필요. 셋은 서로 파일 일부가 겹치므로 task 순서대로 합침
### 병렬 기회
- **Phase 2**: T009·T010은 서로 다른 설정 파일
- **Phase 3 테스트**: T012·T013·T014는 테스트 파일이 겹치지 않음
- **Phase 4**: T027·T028·T029는 `shell/` 아래 독립 파일
- **Phase 8**: T065 문서·메타데이터 정리와 T064 실제 흐름 확인은 독립
### 스토리 안에서
- 테스트 먼저 쓰고 **RED 확인** 후 구현 (CLAUDE.md §1, superpowers:test-driven-development)
- 검증 task 는 그 스토리 구현이 다 끝난 뒤
- 스토리 하나 끝날 때마다 `stage-N.md`**승인 대기 없이 계속** (CLAUDE.md §1 7단계)
---
## Parallel Example: Phase 1 / Phase 4
```bash
# Phase 1 — 문서 3개 동시
Task: "4_rust_tauri/docs-lib/README.md 카탈로그 작성"
Task: "4_rust_tauri/docs-lib/tauri-v2.md — 쓸 API 표면 박제 (+ R11 DPI 설정 위치 확인)"
Task: "2_frontend/docs-lib/tauri-api.md — invoke/listen 표면 박제"
# Phase 4 — shell 3모듈 동시 (서로 독립·콜백도 다름)
Task: "shell/window.rs 재검증 — show/hide/focus/always-on-top/start_dragging"
Task: "shell/hotkey.rs 재검증 — Ctrl+Shift+7/8/9 + 등록 실패 알림"
Task: "shell/tray.rs 재검증 — 열기/항상위/종료"
```
---
## Implementation Strategy
### 첫 실행 가능 상태
1. Phase 2 — Tauri 창에 현재 프론트가 뜸
2. Phase 3 — 화면과 Tauri bridge가 실제 왕복
3. Phase 4 — 표준 단축키·트레이·창 상태가 동작
### 기능 완성 순서
1. US3 — 직전 앱 붙여넣기
2. US4 — 멀티모니터 화면 캡쳐
3. US5 — 기존 스니펫 데이터 읽기·쓰기
4. Phase 8 — 제품 정의·release·완료 보고
US1~US5는 전부 필수다. 비교 결과에 따른 중간 중단은 없다.
---
## Notes
- `3_windowsApp`은 참고용 legacy라 새 기능·기본 배포·완료 검증 대상이 아니다
- Tauri v2 API는 T003의 docs-lib을 보고 작성
- 기존 메시지 계약을 Tauri 사정으로 몰래 바꾸지 않음
- 자동 계측 장치는 만들지 않음
- 편집 후 `npm run format`, 완료 선언 전 lint+test+build (CLAUDE.md §4)
- 작업 스텝마다 `python z-my-docs/work-log/gen_worklog.py add "<한 일>"` (CLAUDE.md §5)
@@ -0,0 +1,34 @@
# 명세 품질 체크리스트: 채팅 이미지 입력
**목적**: 계획 전에 명세의 완성도와 품질 확인
**작성일**: 2026-09-16
**기능**: [spec.md](../spec.md)
## 내용 품질
- [x] 구현 세부사항 없음
- [x] 사용자 가치와 필요에 집중함
- [x] 비개발자도 이해할 수 있음
- [x] 필수 섹션을 모두 작성함
## 요구사항 완성도
- [x] 미확정 표시가 없음
- [x] 요구사항이 테스트 가능하고 분명함
- [x] 성공 기준을 측정할 수 있음
- [x] 성공 기준이 구현 기술에 묶이지 않음
- [x] 인수 시나리오를 정의함
- [x] 예외 상황을 정의함
- [x] 범위가 분명함
- [x] 의존성과 가정을 정의함
## 기능 준비 상태
- [x] 모든 기능 요구사항에 확인 가능한 기준이 있음
- [x] 주요 흐름이 사용자 스토리에 포함됨
- [x] 성공 기준으로 기능을 확인할 수 있음
- [x] 명세에 구현 세부사항이 새지 않음
## 메모
- 전체 항목 통과. 계획 단계 진행 가능.
@@ -0,0 +1,20 @@
# 계약: POST /chat/stream
```ts
interface SnapImageInput {
mediaType: "image/png" | "image/jpeg" | "image/webp"
data: string
}
interface SnapStreamRequest {
sessionId: string
content: string
images?: SnapImageInput[]
forcedSkill?: string
explain?: boolean
}
```
- `content`가 비어 있으면 `images`가 한 장 이상이어야 함.
- `images` 생략 또는 빈 배열은 기존 텍스트 전용 요청과 같음.
- 이미지 원본은 응답과 과거 메시지 조회에 포함되지 않음.
+18
View File
@@ -0,0 +1,18 @@
# 데이터 모델: 채팅 이미지 입력
## SnapImageInput
| 필드 | 형식 | 규칙 |
|---|---|---|
| `mediaType` | 문자열 | `image/png`, `image/jpeg`, `image/webp` 중 하나 |
| `data` | 문자열 | mediaType과 일치하는 base64 data URL |
## 작성 중 질문
| 필드 | 형식 | 규칙 |
|---|---|---|
| 텍스트 | 문자열 | 비어 있어도 이미지가 있으면 전송 가능 |
| 텍스트 첨부 | 문자열 배열 | 기존 100자 이상 붙여넣기 흐름 유지 |
| 이미지 첨부 | `SnapImageInput[]` | 최대 4장, 개별 5 MiB, 전체 15 MiB |
이미지 첨부는 전송용 임시 상태이며 저장된 메시지와 관계를 만들지 않는다.
+49
View File
@@ -0,0 +1,49 @@
# 구현 계획: 채팅 이미지 입력
## 기술 배경
- React 19 + TypeScript + Zustand 기반 `features/snap`
- 기존 SSE 전송기 `streamLLM``/chat/stream` JSON 요청
- Tauri 화면 캡쳐가 만드는 PNG data URL
- 브라우저 Clipboard API가 제공하는 PNG/JPEG/WebP Blob
- Vitest + Testing Library
## 규칙 확인
- `CLAUDE.md`를 유일한 작업 규칙으로 적용함
- 기존 `Composer`, `useSnapChat`, `snapStream`을 재사용함
- 서버 DTO는 `features/snap/contract/types.ts`의 기존 계약 위치에 둠
- 테스트를 먼저 실패시킨 뒤 최소 구현함
- 새 라이브러리는 추가하지 않음
## 설계
1. `SnapImageInput``mediaType`과 data URL `data`로 정의하고 `SnapStreamRequest.images`에 연결한다.
2. `Composer`가 캡쳐와 클립보드 이미지를 같은 첨부 목록으로 관리한다.
3. 첨부 시 형식, 개수, 개별 크기, 전체 크기를 검사한다.
4. `onSend`는 텍스트와 이미지 목록을 받아 새 대화와 기존 대화에서 같은 전송 경로를 쓴다.
5. 새 대화는 세션 생성 뒤 route state로 텍스트와 이미지를 넘기고, 세션 화면이 한 번 소비한다.
6. `useSnapChat`은 로컬 user 메시지를 만든 뒤 백엔드 요청에 `images`를 포함한다.
7. 과거 메시지 DTO에는 이미지 정보를 추가하지 않는다. 원본이 저장되지 않는 백엔드 계약을 따른다.
## 변경 파일
- `2_frontend/src/features/snap/contract/types.ts`
- `2_frontend/src/features/snap/components/Composer.tsx`
- `2_frontend/src/features/snap/components/Composer.test.tsx`
- `2_frontend/src/features/snap/hooks/useSnapChat.ts`
- `2_frontend/src/features/snap/hooks/useSnapChat.test.tsx`
- `2_frontend/src/features/snap/pages/NewChatPage.tsx`
- `2_frontend/src/features/snap/pages/SessionChatPage.tsx`
- `2_frontend/src/features/snap/api/snap.stream.test.ts`
## 위험과 대응
- base64 크기를 문자열 길이로 잘못 계산할 수 있음 → data URL 본문을 디코딩한 byte 길이로 검사
- 새 대화 이동 중 이미지가 사라질 수 있음 → 텍스트와 이미지 모두 route state 한 묶음으로 전달
- 요청 실패 때 작성 내용이 사라질 수 있음 → 전송이 시작되기 전 검증 실패에는 입력과 첨부를 보존함
## 검증
- 단위 테스트: 이미지 계약, 캡쳐 수신, 클립보드 첨부, 이미지-only, 제한 차단, 요청 body
- 전체: `npm run format`, `npm run lint`, `npm run test`, `npm run build`
+19
View File
@@ -0,0 +1,19 @@
# 빠른 검증: 채팅 이미지 입력
## 자동 검사
```powershell
cd 2_frontend
npm run format
npm run lint
npm run test
npm run build
```
## 직접 확인
1. `Ctrl+Shift+9`로 화면을 캡쳐해 새 대화에 썸네일이 붙는지 확인함.
2. 텍스트 없이 전송하고 이미지 내용을 반영한 답변이 오는지 확인함.
3. 기존 대화에서 클립보드 PNG/JPEG/WebP를 붙여 전송함.
4. 첨부 X 버튼과 최대 4장 제한을 확인함.
5. 텍스트만 보내 기존 동작이 유지되는지 확인함.
+21
View File
@@ -0,0 +1,21 @@
# 완료 보고: 채팅 이미지 입력
## 계획 대비 결과
- 백엔드의 `sessionId/content/images/forcedSkill/explain` 계약에 맞춰 Snap 요청 타입을 확장함
- 화면 캡쳐 이미지는 새 대화 생성 뒤에도 유실되지 않고 첫 stream 요청으로 전달됨
- 클립보드 이미지는 새 대화와 기존 대화 Composer에서 질문 첨부로 들어감
- 텍스트 없이 이미지만 전송할 수 있음
- 최대 4장, 장당 5 MiB, 전체 15 MiB, PNG/JPEG/WebP 제한을 적용함
- 백엔드가 원본을 저장하지 않는 계약대로 과거 메시지 DTO와 화면은 바꾸지 않음
## 검증
- `npm run format` 통과
- `npm run lint` 오류 0개, 기존 경고 9개
- `npm run test` 50개 파일, 278개 테스트 통과
- `npm run build` 통과
## 남은 확인
- 배포된 backend에서 `OPENROUTER_API_KEY`, vision 모델, DB migration이 준비된 상태로 실제 이미지 답변을 확인해야 함
+19
View File
@@ -0,0 +1,19 @@
# 조사: 채팅 이미지 입력
## 결정 1: 이미지 표현
- **결정**: 캡쳐와 클립보드 이미지를 `{ mediaType, data }`로 통일함.
- **이유**: 백엔드 `ChatImageInput`의 camelCase 계약과 정확히 맞고 캡쳐 결과가 이미 data URL임.
- **검토한 대안**: multipart 업로드는 백엔드 계약 변경이 필요해 제외함.
## 결정 2: 이미지 보존 범위
- **결정**: 작성 중과 현재 전송에만 이미지를 보존함.
- **이유**: 백엔드가 원본/base64를 저장하거나 응답하지 않음.
- **검토한 대안**: 로컬 영구 저장은 별도 개인정보·수명 관리가 필요해 제외함.
## 결정 3: 클립보드 동작
- **결정**: 이미지가 있으면 첨부 목록에 추가하고, 함께 제공된 텍스트도 기존 규칙대로 반영함.
- **이유**: 사용자가 선택한 범위이며 기존 Clipboard API 코드와 썸네일 UI를 재사용할 수 있음.
- **검토한 대안**: 클립보드 이력에만 보내는 기존 동작은 질문 전송 의도와 어긋나 폐기함.
+57
View File
@@ -0,0 +1,57 @@
# 기능 명세: 채팅 이미지 입력
## 사용자 시나리오 및 테스트
### 사용자 스토리 1 - 캡쳐 이미지로 질문 (P1)
사용자는 화면 캡쳐 이미지를 새 대화에 첨부하고, 텍스트 없이도 이미지 내용을 바탕으로 답변을 받을 수 있다.
**독립 테스트**: 캡쳐 이미지 한 장만 첨부해 전송하면 새 세션이 만들어지고 이미지가 포함된 질문이 처리된다.
### 사용자 스토리 2 - 클립보드 이미지로 질문 (P1)
사용자는 새 대화나 기존 대화에서 클립보드 이미지를 붙여 질문할 수 있다.
**독립 테스트**: PNG, JPEG 또는 WebP 이미지를 클립보드에서 붙이면 썸네일이 보이고 전송 요청에 포함된다.
### 사용자 스토리 3 - 잘못된 첨부를 전송 전에 확인 (P2)
사용자는 허용되지 않는 형식이나 크기의 이미지를 선택했을 때 즉시 이유를 확인하고 질문 내용을 잃지 않는다.
**독립 테스트**: 제한을 넘는 첨부를 시도하면 전송되지 않고 안내가 표시되며 기존 입력과 첨부가 남는다.
## 요구사항
- **FR-001**: 화면 캡쳐 이미지를 새 대화의 첨부로 전송할 수 있어야 한다.
- **FR-002**: 클립보드의 PNG, JPEG, WebP 이미지를 새 대화와 기존 대화에 첨부할 수 있어야 한다.
- **FR-003**: 텍스트 또는 이미지 중 하나만 있어도 전송할 수 있어야 한다.
- **FR-004**: 이미지는 최대 4장, 한 장당 5 MiB, 전체 15 MiB까지만 허용해야 한다.
- **FR-005**: 전송 전 각 이미지의 썸네일을 보여주고 개별 삭제할 수 있어야 한다.
- **FR-006**: 이미지 형식이나 크기 제한을 넘으면 이유를 알려주고 현재 입력을 보존해야 한다.
- **FR-007**: 전송이 성공적으로 시작된 뒤에만 입력과 첨부를 비워야 한다.
- **FR-008**: 이미지가 없는 기존 텍스트 전용 전송 동작을 유지해야 한다.
- **FR-009**: 저장된 과거 대화에는 이미지 원본이나 복원 가능한 썸네일을 표시하지 않아야 한다.
## 주요 데이터
- **이미지 첨부**: 지원 형식, 인코딩된 본문, 원본 크기로 이루어진 일회성 입력이다.
- **작성 중 질문**: 입력 텍스트, 접힌 텍스트 첨부, 이미지 첨부의 묶음이다.
## 성공 기준
- **SC-001**: 사용자가 캡쳐 또는 클립보드 이미지로 3번 이내의 동작을 거쳐 질문을 보낼 수 있다.
- **SC-002**: 텍스트 없이 지원 이미지 한 장만 첨부한 질문이 정상 처리된다.
- **SC-003**: 제한을 넘는 이미지는 서버 요청 전에 모두 차단되고 입력 손실이 없다.
- **SC-004**: 기존 텍스트 전용 채팅 테스트가 모두 통과한다.
## 예외 상황
- 같은 이미지가 여러 번 들어오면 각각 별도 첨부로 취급한다.
- 전송 중에는 새 첨부와 중복 전송을 막는다.
- 클립보드에 텍스트와 이미지가 함께 있으면 둘 다 작성 중 질문에 반영한다.
## 가정
- 서버는 이미지 원본을 저장하지 않으므로 새로고침 후 과거 이미지 미리보기는 복구하지 않는다.
- 화면 캡쳐는 현재처럼 새 대화 화면으로 이동해 첨부된다.
- 네트워크 요청이 시작된 뒤 발생한 서버 오류는 기존 채팅 오류 처리 흐름을 따른다.
+19
View File
@@ -0,0 +1,19 @@
# 단계 1: 이미지 입력 계약 연결
## 한 일
- 캡쳐와 클립보드 이미지를 같은 `SnapImageInput` 계약으로 묶음
- 새 대화 route state와 기존 대화 전송에 이미지 목록을 연결함
- 텍스트 없는 이미지-only 전송과 최대 4장 제한을 지원함
- PNG, JPEG, WebP 및 개별 5 MiB·전체 15 MiB 제한을 프론트에서 먼저 검사함
## 검증 결과
- 이미지 stream body, hook 전달, Composer 이미지-only/4장 제한 테스트 통과
- 전체 테스트 278개 통과
- lint 오류 0개(기존 경고 9개)
- production build 통과
## 다음
- 실제 backend와 붙인 화면 캡쳐·클립보드 수동 확인
+50
View File
@@ -0,0 +1,50 @@
# 작업: 채팅 이미지 입력
## Phase 1: 계약 기반
- [x] T001 `2_frontend/src/features/snap/contract/types.ts`에 이미지 입력과 stream 요청 계약 추가
- [x] T002 [P] `2_frontend/src/features/snap/api/snap.stream.test.ts`에 images 요청 전달 테스트 추가
## Phase 2: 사용자 스토리 1 - 캡쳐 이미지 전송
**독립 테스트**: 캡쳐 한 장만으로 새 세션을 만들고 이미지 요청을 전송함.
- [x] T003 [US1] `2_frontend/src/features/snap/components/Composer.test.tsx`에 캡쳐 이미지-only 전송과 제한 테스트 추가
- [x] T004 [US1] `2_frontend/src/features/snap/components/Composer.tsx`에 이미지 계약 변환·검증·전송 구현
- [x] T005 [US1] `2_frontend/src/features/snap/pages/NewChatPage.tsx``2_frontend/src/features/snap/pages/SessionChatPage.tsx`에 첫 이미지 전달 구현
- [x] T006 [US1] `2_frontend/src/features/snap/hooks/useSnapChat.test.tsx`에 images 요청 body 테스트 추가
- [x] T007 [US1] `2_frontend/src/features/snap/hooks/useSnapChat.ts`에 이미지 전송 연결
## Phase 3: 사용자 스토리 2 - 클립보드 이미지 전송
**독립 테스트**: 기존 대화에서 지원 이미지를 클립보드로 가져오면 썸네일과 함께 요청에 포함됨.
- [x] T008 [US2] `2_frontend/src/features/snap/components/Composer.test.tsx`에 클립보드 이미지 첨부 테스트 추가
- [x] T009 [US2] `2_frontend/src/features/snap/components/Composer.tsx`에서 클립보드 이미지를 질문 첨부로 누적
- [x] T010 [US2] `2_frontend/src/features/snap/pages/SessionChatPage.tsx`에서 기존 대화 이미지 입력 허용
## Phase 4: 사용자 스토리 3 - 제한과 입력 보존
**독립 테스트**: 형식·개수·크기 제한을 넘으면 요청 없이 안내하고 작성 내용을 유지함.
- [x] T011 [US3] `2_frontend/src/features/snap/components/Composer.test.tsx`에 형식·개수·크기·입력 보존 회귀 테스트 추가
- [x] T012 [US3] `2_frontend/src/features/snap/components/Composer.tsx`의 오류 안내와 경계 처리를 완성
## Phase 5: 마무리
- [x] T013 `specs/005-chat-image-input/stage-1.md`에 구현 및 단계 검증 결과 기록
- [x] T014 `2_frontend`에서 format, lint, test, build 실행
- [x] T015 `specs/005-chat-image-input/report.md`에 계획 대비 결과와 검증 기록
## 의존 관계
- T001 뒤에 T003~T012를 진행함.
- US1이 새 대화 전달 경로를 완성하고, US2는 같은 Composer 계약을 기존 대화까지 넓힘.
- US3은 US1·US2의 공통 입력 경계를 잠금.
## 구현 전략
- MVP는 US1의 캡쳐 이미지-only 전송임.
- 다음으로 기존 대화 클립보드 입력을 연결하고 마지막에 제한 회귀를 잠금.
- 모든 구현 작업은 대응 테스트 실패를 먼저 확인한 뒤 통과시킴.