Initial Commit
This commit is contained in:
@@ -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)
|
||||
```
|
||||
@@ -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 갱신 → 랭킹 상단으로 부상.
|
||||
@@ -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 타입). 별도 정당화 표 불필요.
|
||||
@@ -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)
|
||||
@@ -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 준비 완료**.
|
||||
@@ -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` 버전 핀 — 구현 시 최신 안정.
|
||||
@@ -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` 사용은 의도된 선택**이며, 다른 앱의 동일 단축키를 가로채는 점은 감수한다(핫키는 설정 한 곳에서 교체 가능).
|
||||
@@ -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 안 옴, 캡쳐 기능 비활성(데스크톱 전용). 기존 챗은 그대로.
|
||||
@@ -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·무효선택) → 아무 상태 변화 없음.
|
||||
@@ -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에 정당화됨. 별도 표 불필요.
|
||||
@@ -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)
|
||||
@@ -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 준비 완료.** 단 오버레이·캡쳐·멀티모니터 런타임은 헤드리스 미검 → 사용자 수동 확인 권장.
|
||||
@@ -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 설정 여부 확인(이미 있으면 재사용).
|
||||
@@ -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 포맷 포함).
|
||||
- 기존(진행 중) 세션에 캡쳐 붙이기, 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.
|
||||
@@ -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` 전에 반드시 확정 필요
|
||||
@@ -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와 브라우저가 같은 화면 결과물 사용 | 빌드 설정 + 실제 실행 확인 |
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
@@ -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는 생성기가 만든 유효한 산출물 | 통째 재생성해도 같은 파일만 다시 생김. 손으로 쓴 소스만 컴파일하며 재검증함 |
|
||||
@@ -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`에 계획 대비 구현 결과와 검증 증거를 남긴다.
|
||||
@@ -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 설정이 아니라 논리↔물리 좌표 변환을 본다.
|
||||
@@ -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 비교·설치물 비교·판정 기록은 범위에서 뺀다
|
||||
- “비교하다 결론이 나면 중간에 멈춤” 규칙을 없앤다
|
||||
- 아직 안 만든 기능은 구현 중에만 분명한 오류로 알리고, 완료 시점에는 남아 있으면 안 된다
|
||||
@@ -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` 기준으로 바로 걷어냄
|
||||
@@ -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와 붙여넣기를 완성함
|
||||
@@ -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를 확인함.
|
||||
@@ -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 미구현은 그대로 남김. 이번 개선 완료와 전체 스니펫 기능 완료는 구분함.
|
||||
@@ -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` 생략 또는 빈 배열은 기존 텍스트 전용 요청과 같음.
|
||||
- 이미지 원본은 응답과 과거 메시지 조회에 포함되지 않음.
|
||||
@@ -0,0 +1,18 @@
|
||||
# 데이터 모델: 채팅 이미지 입력
|
||||
|
||||
## SnapImageInput
|
||||
|
||||
| 필드 | 형식 | 규칙 |
|
||||
|---|---|---|
|
||||
| `mediaType` | 문자열 | `image/png`, `image/jpeg`, `image/webp` 중 하나 |
|
||||
| `data` | 문자열 | mediaType과 일치하는 base64 data URL |
|
||||
|
||||
## 작성 중 질문
|
||||
|
||||
| 필드 | 형식 | 규칙 |
|
||||
|---|---|---|
|
||||
| 텍스트 | 문자열 | 비어 있어도 이미지가 있으면 전송 가능 |
|
||||
| 텍스트 첨부 | 문자열 배열 | 기존 100자 이상 붙여넣기 흐름 유지 |
|
||||
| 이미지 첨부 | `SnapImageInput[]` | 최대 4장, 개별 5 MiB, 전체 15 MiB |
|
||||
|
||||
이미지 첨부는 전송용 임시 상태이며 저장된 메시지와 관계를 만들지 않는다.
|
||||
@@ -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`
|
||||
@@ -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. 텍스트만 보내 기존 동작이 유지되는지 확인함.
|
||||
@@ -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이 준비된 상태로 실제 이미지 답변을 확인해야 함
|
||||
@@ -0,0 +1,19 @@
|
||||
# 조사: 채팅 이미지 입력
|
||||
|
||||
## 결정 1: 이미지 표현
|
||||
|
||||
- **결정**: 캡쳐와 클립보드 이미지를 `{ mediaType, data }`로 통일함.
|
||||
- **이유**: 백엔드 `ChatImageInput`의 camelCase 계약과 정확히 맞고 캡쳐 결과가 이미 data URL임.
|
||||
- **검토한 대안**: multipart 업로드는 백엔드 계약 변경이 필요해 제외함.
|
||||
|
||||
## 결정 2: 이미지 보존 범위
|
||||
|
||||
- **결정**: 작성 중과 현재 전송에만 이미지를 보존함.
|
||||
- **이유**: 백엔드가 원본/base64를 저장하거나 응답하지 않음.
|
||||
- **검토한 대안**: 로컬 영구 저장은 별도 개인정보·수명 관리가 필요해 제외함.
|
||||
|
||||
## 결정 3: 클립보드 동작
|
||||
|
||||
- **결정**: 이미지가 있으면 첨부 목록에 추가하고, 함께 제공된 텍스트도 기존 규칙대로 반영함.
|
||||
- **이유**: 사용자가 선택한 범위이며 기존 Clipboard API 코드와 썸네일 UI를 재사용할 수 있음.
|
||||
- **검토한 대안**: 클립보드 이력에만 보내는 기존 동작은 질문 전송 의도와 어긋나 폐기함.
|
||||
@@ -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**: 기존 텍스트 전용 채팅 테스트가 모두 통과한다.
|
||||
|
||||
## 예외 상황
|
||||
|
||||
- 같은 이미지가 여러 번 들어오면 각각 별도 첨부로 취급한다.
|
||||
- 전송 중에는 새 첨부와 중복 전송을 막는다.
|
||||
- 클립보드에 텍스트와 이미지가 함께 있으면 둘 다 작성 중 질문에 반영한다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 서버는 이미지 원본을 저장하지 않으므로 새로고침 후 과거 이미지 미리보기는 복구하지 않는다.
|
||||
- 화면 캡쳐는 현재처럼 새 대화 화면으로 이동해 첨부된다.
|
||||
- 네트워크 요청이 시작된 뒤 발생한 서버 오류는 기존 채팅 오류 처리 흐름을 따른다.
|
||||
@@ -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와 붙인 화면 캡쳐·클립보드 수동 확인
|
||||
@@ -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 전송임.
|
||||
- 다음으로 기존 대화 클립보드 입력을 연결하고 마지막에 제한 회귀를 잠금.
|
||||
- 모든 구현 작업은 대응 테스트 실패를 먼저 확인한 뒤 통과시킴.
|
||||
|
||||
Reference in New Issue
Block a user