17 task / 9 phase: - Phase 1 (T1-2): m004 schema (status/status_changed_at/move_reason) + NoteRepository.setStatus/listByStatus + restoreNote 재구현 - Phase 2 (T3): ai_status 'disabled' enum + CaptureService aiEnabled 분기 (skip pending_jobs) - Phase 3 (T4-5): useInbox view enum 4탭 + 헤더 4탭 UI + listByStatus IPC - Phase 4 (T6-8): NoteCard 액션 메뉴 + MoveStatusModal (사유 입력 + 4 status 버튼) + setStatus IPC - Phase 5 (T9-10): classifyStatus AI prompt + ai:classify-status IPC + AI 추천 UI - Phase 6 (T11-12): OnboardingWizard 3 옵션 + 설치 가이드 + App.tsx 첫 launch 분기 - Phase 7 (T13-14): NoteCard ai_status='disabled' fallback (raw_text 첫 줄) + Banner ai_enabled=false 비활성 + HealthChecker polling 중단 - Phase 8 (T15-16): AiProviderSection AI 자동 처리 토글 + requeueDisabled (ON 전환 후 처리 버튼) - Phase 9 (T17): 회귀 + dogfood F17/F18/F23 promoted + version 0.2.9 bump 선행 spec: 2026-05-09-v029-cut-b-design.md. 단위 472 → 약 510 (+38) 목표. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
61 KiB
v0.2.9 Cut B Implementation Plan — status 4분기 + 사유 + Ollama-less
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: F17 (status 4분기 + AI 자동 분류) + F18 (자유 텍스트 사유) + F23 (Ollama-less 모드 onboarding wizard + 설정 토글 + raw-only NoteCard fallback) — 데이터 모델 정비 cut.
Architecture: notes 테이블에 status 컬럼 (4분기: active/completed/archived/trashed) + move_reason 자유 텍스트 + status_changed_at 추가. ai_status enum 에 disabled 신규 값 (application-level zod 검증). useInbox view enum 4탭 확장. NoteCard 액션 메뉴 + 사유 입력 modal + AI 자동 분류 버튼 (사유 + raw_text → AI prompt → recommended status + rationale → 사용자 confirm). 첫 launch onboarding wizard (3 옵션: AI 사용 / 원문만 / 나중에) + 설정 페이지 "AI 자동 처리 사용" 토글 + ai_enabled false 시 capture path skip pending_jobs + Banner 비활성.
Tech Stack: Electron 41 + React 19 + better-sqlite3 12.9 + zod 4.3.6 + zustand 5
선행 spec: docs/superpowers/specs/2026-05-09-v029-cut-b-design.md
File Structure
신규 파일
| 경로 | 책임 |
|---|---|
src/main/db/migrations/m004.ts |
status 컬럼 + status_changed_at + move_reason 추가 + 기존 deleted_at != NULL → status='trashed' migrate |
src/renderer/inbox/components/MoveStatusModal.tsx |
사유 입력 modal + 4 status 버튼 + AI 자동 분류 버튼 |
src/renderer/inbox/components/OnboardingWizard.tsx |
첫 launch 3 옵션 onboarding |
src/main/ai/classifyStatus.ts |
AiWorker.classifyStatus — reason + raw_text + summary → AI prompt → { recommended, rationale } |
위 컴포넌트들의 .test.ts(x) |
단위 테스트 (vitest + RTL) |
수정 파일
| 경로 | 변경 |
|---|---|
src/main/db/index.ts |
m004 migration 등록 |
src/shared/types.ts |
NoteStatus enum + Note.status/statusChangedAt/moveReason field + InboxApi.setStatus / classifyStatus 시그니처 + AiStatus 에 'disabled' enum 추가 |
src/main/repository/NoteRepository.ts |
setStatus(id, status, reason) / listByStatus(status, opts) 메서드 추가, 기존 restoreNote → setStatus(id, 'active', null) 재구현 |
src/main/services/CaptureService.ts |
ai_enabled settings 조회 → false 시 ai_status='disabled' + pending_jobs skip |
src/main/services/SettingsService.ts |
settings 스키마에 ai_enabled: boolean (default true) + onboarding_completed: boolean (default false) 추가 |
src/main/ai/AiWorker.ts |
classifyStatus 메서드 추가 (현 generate 와 별도 path) |
src/main/health/HealthChecker.ts |
ai_enabled=false 시 polling 중단 |
src/main/ipc/inboxApi.ts |
inbox:set-status + ai:classify-status IPC 핸들러 |
src/main/ipc/settingsApi.ts |
settings 에 ai_enabled / onboarding_completed 토글 IPC |
src/preload/index.ts |
신규 채널 화이트리스트 |
src/renderer/inbox/api.ts |
wrapper (setStatus / classifyStatus / setAiEnabled / setOnboardingCompleted) |
src/renderer/inbox/store.ts |
view enum 확장 (inbox/completed/archived/trash/settings) + count fields per status |
src/renderer/inbox/App.tsx |
헤더 4탭 + Onboarding wizard 첫 launch 분기 |
src/renderer/inbox/components/NoteCard.tsx |
액션 메뉴 (완료/보관/휴지통) + ai_status='disabled' fallback (title = raw_text 첫 줄) |
src/renderer/inbox/components/OllamaBanner.tsx + FailedBanner.tsx |
ai_enabled=false 시 render skip |
src/renderer/inbox/components/settings/AiProviderSection.tsx |
상단 "AI 자동 처리 사용" 토글 + disabled 메모 N건 처리 버튼 |
Phase 개요
Phase 1: m004 schema + repo (Task 1, 2)
Phase 2: ai_status 'disabled' + capture skip (Task 3)
Phase 3: 4탭 UI 인프라 (Task 4, 5)
Phase 4: 사유 입력 modal (Task 6, 7, 8)
Phase 5: AI 자동 분류 (Task 9, 10)
Phase 6: Onboarding wizard (Task 11, 12)
Phase 7: AI off NoteCard fallback + Banner 비활성 (Task 13, 14)
Phase 8: 설정 페이지 토글 + disabled 메모 처리 (Task 15, 16)
Phase 9: verification + version bump (Task 17)
Phase 1: m004 schema + NoteRepository
Task 1: m004 마이그레이션 — status 컬럼 추가
Files:
-
Create:
src/main/db/migrations/m004.ts -
Modify:
src/main/db/index.ts(migration 등록) -
Test:
tests/unit/m004-migration.test.ts -
Step 1: failing test
// tests/unit/m004-migration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { applyM004 } from '../../src/main/db/migrations/m004';
describe('m004 migration — status column', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
// m003 baseline (notes 테이블 with deleted_at) 가정 — 기존 마이그레이션 적용
db.exec(`
CREATE TABLE notes (
id TEXT PRIMARY KEY,
raw_text TEXT NOT NULL,
title TEXT,
summary TEXT,
tags_csv TEXT,
ai_status TEXT NOT NULL DEFAULT 'pending',
ai_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, deleted_at)
VALUES ('a', 't1', 'complete', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', NULL),
('b', 't2', 'complete', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', '2026-05-08T00:00:00Z');
`);
});
it('adds status / status_changed_at / move_reason columns', () => {
applyM004(db);
const cols = db.prepare(`PRAGMA table_info(notes)`).all() as Array<{ name: string }>;
const names = cols.map(c => c.name);
expect(names).toContain('status');
expect(names).toContain('status_changed_at');
expect(names).toContain('move_reason');
});
it('default status="active" for non-deleted notes', () => {
applyM004(db);
const a = db.prepare(`SELECT status FROM notes WHERE id=?`).get('a') as { status: string };
expect(a.status).toBe('active');
});
it('migrates deleted_at != NULL to status="trashed" + status_changed_at', () => {
applyM004(db);
const b = db.prepare(`SELECT status, status_changed_at FROM notes WHERE id=?`).get('b') as { status: string; status_changed_at: string };
expect(b.status).toBe('trashed');
expect(b.status_changed_at).toBe('2026-05-08T00:00:00Z');
});
it('move_reason NULL by default', () => {
applyM004(db);
const a = db.prepare(`SELECT move_reason FROM notes WHERE id=?`).get('a') as { move_reason: string | null };
expect(a.move_reason).toBeNull();
});
});
- Step 2: 테스트 실행 → fail
npm run rebuild:node
npx vitest run tests/unit/m004-migration.test.ts
Expected: Cannot find module '../../src/main/db/migrations/m004'.
- Step 3: m004.ts 작성
// src/main/db/migrations/m004.ts
import type Database from 'better-sqlite3';
export function applyM004(db: Database.Database): void {
db.exec(`
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
ALTER TABLE notes ADD COLUMN move_reason TEXT;
`);
// 기존 deleted_at != NULL → status='trashed' + status_changed_at = deleted_at
db.prepare(`
UPDATE notes SET status='trashed', status_changed_at=deleted_at
WHERE deleted_at IS NOT NULL
`).run();
}
- Step 4: 테스트 통과 확인
npx vitest run tests/unit/m004-migration.test.ts
Expected: 4 tests pass.
- Step 5: src/main/db/index.ts 의 migration registry 갱신
기존 applyM003 다음에 applyM004 추가. 마이그레이션 호출 chain (주로 runMigrations() 함수 안 또는 비슷한 곳) 에 m004 등록. 기존 패턴 따름.
- Step 6: typecheck + 전체 회귀
npm run typecheck
npx vitest run
Expected: 0 errors + 472 → 476 (+4).
- Step 7: commit
git add src/main/db/migrations/m004.ts src/main/db/index.ts tests/unit/m004-migration.test.ts
git commit -m "feat(v029): m004 마이그레이션 — status/status_changed_at/move_reason 컬럼"
Task 2: NoteRepository.setStatus + listByStatus + restoreNote 재구현
Files:
-
Modify:
src/main/repository/NoteRepository.ts -
Modify:
src/shared/types.ts(NoteStatus + Note 필드) -
Modify:
tests/unit/NoteRepository.test.ts -
Step 1: failing test
// tests/unit/NoteRepository.test.ts 안 (기존 describe 블록에 추가)
describe('NoteRepository — setStatus + listByStatus', () => {
// (기존 setup helper 활용)
it('setStatus updates status + reason + status_changed_at', () => {
const noteId = repo.create({ rawText: 'test', now: new Date('2026-05-09T00:00:00Z') }).id;
repo.setStatus(noteId, 'completed', '결재 끝', new Date('2026-05-10T00:00:00Z'));
const note = repo.getById(noteId);
expect(note?.status).toBe('completed');
expect(note?.moveReason).toBe('결재 끝');
expect(note?.statusChangedAt).toBe('2026-05-10T00:00:00Z');
});
it('listByStatus filters correctly', () => {
repo.create({ rawText: 'a', now: new Date() });
const idB = repo.create({ rawText: 'b', now: new Date() }).id;
repo.setStatus(idB, 'archived', null, new Date());
const active = repo.listByStatus('active', { limit: 10 });
const archived = repo.listByStatus('archived', { limit: 10 });
expect(active.map(n => n.rawText)).toContain('a');
expect(archived.map(n => n.rawText)).toContain('b');
});
it('restoreNote sets status=active with null reason', () => {
const id = repo.create({ rawText: 'r', now: new Date() }).id;
repo.setStatus(id, 'trashed', null, new Date());
repo.restoreNote(id);
const note = repo.getById(id);
expect(note?.status).toBe('active');
expect(note?.moveReason).toBeNull();
});
});
- Step 2: 테스트 실행 → fail
npx vitest run tests/unit/NoteRepository.test.ts
Expected: setStatus / listByStatus 미존재 → fail.
- Step 3: types.ts 갱신
// src/shared/types.ts
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
export interface Note {
// ... 기존 필드
status: NoteStatus;
statusChangedAt: string | null;
moveReason: string | null;
}
- Step 4: NoteRepository 메서드 추가
// src/main/repository/NoteRepository.ts
setStatus(id: string, status: NoteStatus, reason: string | null, now: Date = new Date()): void {
const tx = this.db.transaction(() => {
this.db.prepare(`
UPDATE notes
SET status=?, move_reason=?, status_changed_at=?, updated_at=?
WHERE id=?
`).run(status, reason, now.toISOString(), now.toISOString(), id);
// status='trashed' 시 deleted_at 도 동기화 (backward compat — 기존 코드 가 deleted_at 참조)
if (status === 'trashed') {
this.db.prepare(`UPDATE notes SET deleted_at=? WHERE id=?`).run(now.toISOString(), id);
} else {
this.db.prepare(`UPDATE notes SET deleted_at=NULL WHERE id=?`).run(id);
}
});
tx();
}
listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
const limit = opts.limit ?? 200;
const rows = this.db.prepare(`
SELECT * FROM notes WHERE status=?
ORDER BY status_changed_at DESC, created_at DESC
LIMIT ?
`).all(status, limit);
return rows.map(r => this.hydrate(r as Record<string, unknown>));
}
restoreNote 재구현 — 기존 repo.restoreNote(id) 본문을 setStatus(id, 'active', null) 로 교체:
restoreNote(id: string): void {
this.setStatus(id, 'active', null);
// 기존 m003 의 ai_status='failed' → 'pending' 재투입 로직 보존
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id=?`).get(id) as { ai_status: string } | undefined;
if (before?.ai_status === 'failed') {
const now = new Date().toISOString();
this.db.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`).run(now, id);
this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, now);
} else if (before?.ai_status === 'pending') {
const now = new Date().toISOString();
this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, now);
}
}
hydrate 메서드도 status / status_changed_at / move_reason 매핑 추가:
private hydrate(row: Record<string, unknown>): Note {
return {
// ... 기존
status: (row.status as NoteStatus) ?? 'active',
statusChangedAt: (row.status_changed_at as string | null) ?? null,
moveReason: (row.move_reason as string | null) ?? null
};
}
- Step 5: 테스트 + typecheck + 회귀
npx vitest run tests/unit/NoteRepository.test.ts
npm run typecheck
npx vitest run
Expected: 신규 3 test pass + 회귀 476 → 479.
- Step 6: commit
git add src/main/repository/NoteRepository.ts src/shared/types.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(v029): NoteRepository.setStatus + listByStatus + restoreNote 재구현"
Phase 2: ai_status='disabled' + capture skip
Task 3: ai_status 'disabled' enum + CaptureService aiEnabled 분기
Files:
-
Modify:
src/main/services/telemetryEvents.ts(또는 ai_status enum 위치) — 'disabled' 추가 -
Modify:
src/main/services/SettingsService.ts—ai_enabledfield schema -
Modify:
src/main/services/CaptureService.ts— aiEnabled 분기 -
Modify:
tests/unit/CaptureService.test.ts -
Step 1: ai_status enum 'disabled' 추가
src/shared/types.ts 또는 ai_status 정의 위치 (현재 v0.2.6 의 AiFailedReason 통합 commit 위치 참조):
// 기존 AiStatus enum 또는 zod schema:
export const AiStatusSchema = z.enum(['pending', 'processing', 'complete', 'failed', 'disabled']);
export type AiStatus = z.infer<typeof AiStatusSchema>;
만약 ai_status 가 application-level 검증만이고 SQLite CHECK 부재 (spec §5-1) — application zod 추가만으로 충분.
- Step 2: SettingsService schema 갱신
// src/main/services/SettingsService.ts (또는 schema 정의 위치)
export const SettingsSchema = z.object({
// ... 기존
ai_enabled: z.boolean().default(true),
onboarding_completed: z.boolean().default(false)
});
export type Settings = z.infer<typeof SettingsSchema>;
기존 SettingsService 의 get/set 메서드 가 새 field 자동 인식. zod default 가 fallback.
- Step 3: failing test (CaptureService 분기)
// tests/unit/CaptureService.test.ts 추가
describe('CaptureService — ai_enabled false', () => {
it('skips pending_jobs enqueue when ai_enabled=false', async () => {
const settings = makeMockSettings({ ai_enabled: false });
const enqueue = vi.fn(async () => {});
const svc = new CaptureService({ repo, settings, enqueue, /* 기타 deps */ });
const r = await svc.create({ rawText: 'test' });
expect(enqueue).not.toHaveBeenCalled();
const note = repo.getById(r.id);
expect(note?.aiStatus).toBe('disabled');
});
it('enqueues normally when ai_enabled=true (default)', async () => {
const settings = makeMockSettings({ ai_enabled: true });
const enqueue = vi.fn(async () => {});
const svc = new CaptureService({ repo, settings, enqueue });
const r = await svc.create({ rawText: 'test' });
expect(enqueue).toHaveBeenCalledWith(r.id);
});
});
- Step 4: 테스트 실행 → fail
npx vitest run tests/unit/CaptureService.test.ts
- Step 5: CaptureService 갱신
// src/main/services/CaptureService.ts
async create(input: { rawText: string; images?: ... }): Promise<{ id: string }> {
const aiEnabled = await this.deps.settings.get('ai_enabled', true);
const initialAiStatus = aiEnabled ? 'pending' : 'disabled';
const note = this.deps.repo.insert({ ...input, aiStatus: initialAiStatus });
if (aiEnabled) {
await this.deps.enqueue(note.id);
}
return { id: note.id };
}
(deps 인터페이스에 settings: SettingsService 추가 — main process 가 inject. NoteRepository.insert 의 aiStatus 인자 가 신규 — repo 도 갱신.)
NoteRepository.insert 의 INSERT 문 — ai_status 가 input 으로 받도록 갱신:
insert(input: { ..., aiStatus?: AiStatus }): Note {
const ai_status = input.aiStatus ?? 'pending';
this.db.prepare(`INSERT INTO notes (..., ai_status, ...) VALUES (..., ?, ...)`).run(..., ai_status, ...);
}
- Step 6: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
Expected: 신규 2 test + 기존 회귀 479 → 481.
- Step 7: commit
git add src/shared/types.ts src/main/services/SettingsService.ts src/main/services/CaptureService.ts src/main/repository/NoteRepository.ts tests/unit/CaptureService.test.ts
git commit -m "feat(v029): ai_status 'disabled' enum + CaptureService ai_enabled 분기 (skip pending_jobs)"
Phase 3: 4탭 UI 인프라
Task 4: useInbox view enum + count fields per status
Files:
-
Modify:
src/renderer/inbox/store.ts -
Modify:
tests/unit/store.showSettings.test.ts(또는 store.test.ts 신규) -
Step 1: failing test
// tests/unit/store.view.test.ts (신규)
import { describe, it, expect, beforeEach } from 'vitest';
import { useInbox } from '../../src/renderer/inbox/store';
describe('inbox store — view enum', () => {
beforeEach(() => {
useInbox.setState({
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 }
});
});
it('initial view is inbox', () => {
expect(useInbox.getState().view).toBe('inbox');
});
it('setView changes view', () => {
useInbox.getState().setView('completed');
expect(useInbox.getState().view).toBe('completed');
});
it('counts initialized to zero per status', () => {
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 });
});
});
-
Step 2: 테스트 → fail
-
Step 3: store.ts 갱신
기존 showTrash: boolean + showSettings: boolean 2개 boolean 을 view: 'inbox' | 'completed' | 'archived' | 'trash' | 'settings' 로 통합:
// src/renderer/inbox/store.ts
type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings';
interface InboxState {
// ... 기존
view: InboxView;
counts: { active: number; completed: number; archived: number; trashed: number };
setView: (view: InboxView) => void;
// 기존 toggleShowTrash / showTrash / showSettings / setShowSettings — DEPRECATED. setView 로 통합.
// backward compat 위해 일시 잔류 가능 OR 즉시 제거 (caller 갱신 필요).
}
// initial:
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
// action:
setView(view) { set({ view }); /* 필요 시 데이터 fetch */ },
기존 showTrash / showSettings 와 toggleShowTrash / setShowSettings 호출부 모두 view enum 으로 갱신 — Task 5 에서 본격 처리.
- Step 4: 테스트 + typecheck
기존 showTrash 사용처가 broken — Task 5 에서 정리할 것이므로 잠시 broken state 받아들임. 또는 backward-compat 으로 showTrash: get().view === 'trash' computed 형태로 잠시 유지 가능.
추천: backward-compat — store 안에 get showTrash() { return this.view === 'trash'; } 같은 getter 또는 selector 함수 추가. caller 들 점진적 갱신.
// 임시 backward-compat (Task 5 머지 후 제거)
showTrash: false, // computed below
showSettings: false,
// store update 시 view 변경할 때 같이 갱신:
setView(view) {
set({ view, showTrash: view === 'trash', showSettings: view === 'settings' });
}
- Step 5: commit
git add src/renderer/inbox/store.ts tests/unit/store.view.test.ts
git commit -m "feat(v029): inbox store view enum 4탭 + counts per status (backward-compat showTrash/showSettings)"
Task 5: 헤더 4탭 + count badge
Files:
-
Modify:
src/renderer/inbox/App.tsx -
Modify:
tests/unit/App.test.tsx -
Step 1: failing test
// tests/unit/App.test.tsx — 추가
describe('App header — 4 tabs', () => {
it('renders 4 tabs (Inbox / 완료 / 보관 / 휴지통)', () => {
render(<App />);
expect(screen.getByRole('button', { name: /Inbox/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^완료/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^보관/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^휴지통/ })).toBeInTheDocument();
});
it('clicking 완료 tab sets view=completed', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: /^완료/ }));
expect(useInbox.getState().view).toBe('completed');
});
});
-
Step 2: 테스트 → fail
-
Step 3: App.tsx 갱신 — 4탭
기존 2탭 (Inbox / 휴지통) → 4탭. 기존 tabBtnStyle 유지.
const view = useInbox(s => s.view);
const setView = useInbox(s => s.setView);
const counts = useInbox(s => s.counts);
const tabs: Array<{ key: InboxView; label: string; count: number }> = [
{ key: 'inbox', label: 'Inbox', count: counts.active },
{ key: 'completed', label: '완료', count: counts.completed },
{ key: 'archived', label: '보관', count: counts.archived },
{ key: 'trash', label: '휴지통', count: counts.trashed }
];
// 헤더 안:
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
{tabs.map(t => (
<button
key={t.key}
onClick={() => setView(t.key)}
aria-pressed={view === t.key}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})
</button>
))}
</div>
view='settings' 분기는 기존대로 유지. notes list rendering 시 useInbox(s => s.notes) 가 current view 에 맞는 노트 (active / completed / archived / trash) 자동 반환되도록 store action 갱신 — loadInitial / setView 시 해당 status fetch.
- Step 4: store loadInitial + setView 갱신
// store.ts — setView 가 해당 view 의 notes fetch
setView(view) {
set({ view, showTrash: view === 'trash', showSettings: view === 'settings' });
if (view !== 'settings' && view !== 'inbox') {
void get().loadByView(view); // 새 helper
} else if (view === 'inbox') {
void get().loadInitial();
}
}
async loadByView(view: 'completed' | 'archived' | 'trash'): Promise<void> {
const status = view === 'trash' ? 'trashed' : view;
const notes = await inboxApi.listByStatus(status, { limit: 200 });
set({ notes }); // 또는 별도 cache map per status
}
(IPC inbox:list-by-status 도 신규 — Task 6 또는 본 task 에서 추가 가능. 본 task 에서 함께.)
- Step 5: IPC 핸들러 추가
// src/main/ipc/inboxApi.ts
ipcMain.handle('inbox:list-by-status', async (_e, status: NoteStatus, opts: { limit?: number }) => {
return repo.listByStatus(status, opts);
});
api.ts wrapper + preload + types.ts InboxApi 갱신:
listByStatus(status: NoteStatus, opts: { limit?: number }): Promise<Note[]>;
- Step 6: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
Expected: 회귀 481 → 484 (+3).
- Step 7: commit
git add src/renderer/inbox/App.tsx src/renderer/inbox/store.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts src/renderer/inbox/api.ts tests/unit/App.test.tsx
git commit -m "feat(v029): 헤더 4탭 (Inbox/완료/보관/휴지통) + listByStatus IPC"
Phase 4: 사유 입력 modal
Task 6: NoteCard 액션 메뉴 (이동 버튼)
Files:
-
Modify:
src/renderer/inbox/components/NoteCard.tsx -
Modify:
tests/unit/NoteCard.test.tsx -
Step 1: failing test
it('renders move action menu (완료 / 보관 / 휴지통)', () => {
render(<NoteCard note={baseNote} mode="inbox" onUpdated={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /이동/ }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관함으로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
});
it('clicking "완료로 이동" opens MoveStatusModal with status preset', () => {
render(<NoteCard note={baseNote} mode="inbox" onUpdated={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /이동/ }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
expect(screen.getByRole('dialog', { name: /이동/ })).toBeInTheDocument();
});
-
Step 2: 테스트 → fail
-
Step 3: NoteCard 갱신
기존 휴지통 버튼 1개 → 메뉴 (dropdown 또는 inline group):
import { useState } from 'react';
import { MoveStatusModal } from './MoveStatusModal';
// NoteCard 안:
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
// 기존 휴지통 버튼 자리 — view='inbox' 이면:
{mode === 'inbox' && (
<div style={{ position: 'relative' }}>
<button onClick={() => setMenuOpen(o => !o)} aria-label="이동" style={{ ... }}>이동 ▾</button>
{menuOpen && (
<div style={{ position: 'absolute', right: 0, top: '100%', background: '#fff', border: '1px solid #ccc', borderRadius: 4, padding: 4 }}>
<button onClick={() => { setMoveTarget('completed'); setMenuOpen(false); }}>완료로 이동</button>
<button onClick={() => { setMoveTarget('archived'); setMenuOpen(false); }}>보관함으로 이동</button>
<button onClick={() => { setMoveTarget('trashed'); setMenuOpen(false); }}>휴지통으로 이동</button>
</div>
)}
</div>
)}
{moveTarget && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.summary ?? ''}
initialTarget={moveTarget}
onClose={() => setMoveTarget(null)}
onMoved={(newStatus, reason) => {
onUpdated({ ...local, status: newStatus, moveReason: reason });
setMoveTarget(null);
}}
/>
)}
mode='completed'/'archived' 등 다른 view 의 NoteCard 도 비슷한 메뉴 (예: 보관에서 active 로 복귀, 휴지통에서 restore). MoveStatusModal 의 inverse 동작 — 같은 컴포넌트 활용.
- Step 4: 테스트 + typecheck
MoveStatusModal 미존재라 import error — Task 7 에서 작성. 임시로 stub component 또는 skip 검사.
// 임시 stub — Task 7 까지 jsdom 에서 안 깨지도록:
function MoveStatusModal(props: any) { return <div role="dialog" aria-label="이동">stub</div>; }
또는 MoveStatusModal 을 Task 7 에서 작성 후 NoteCard import 갱신 — 한 PR 안 두 commit 분리.
추천: Task 6 안 NoteCard 갱신 + 임시 inline stub MoveStatusModal — Task 7 에서 별 파일로 추출 + 정식 구현.
- Step 5: commit
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v029): NoteCard 이동 메뉴 (완료/보관/휴지통) + MoveStatusModal stub"
Task 7: MoveStatusModal — 사유 입력 + 4 status 버튼
Files:
-
Create:
src/renderer/inbox/components/MoveStatusModal.tsx -
Create:
tests/unit/MoveStatusModal.test.tsx -
Modify:
src/renderer/inbox/components/NoteCard.tsx(stub → import) -
Step 1: failing test
// tests/unit/MoveStatusModal.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
const mockSetStatus = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
setStatus: mockSetStatus,
classifyStatus: vi.fn(async () => ({ recommended: 'completed', rationale: '결재 끝' }))
}
}));
import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
describe('MoveStatusModal', () => {
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
it('renders reason textarea + 4 status buttons + AI classify button', () => {
render(
<MoveStatusModal noteId="n1" rawText="t" summary="" initialTarget="completed"
onClose={vi.fn()} onMoved={vi.fn()} />
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^완료$/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^보관$/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^휴지통$/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
});
it('clicking 완료 calls setStatus with reason', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal noteId="n1" rawText="t" summary="" initialTarget="completed"
onClose={vi.fn()} onMoved={onMoved} />
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: /^완료$/ }));
await vi.waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
});
});
});
-
Step 2: 테스트 → fail
-
Step 3: MoveStatusModal.tsx 작성
// src/renderer/inbox/components/MoveStatusModal.tsx
import React, { useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';
interface Props {
noteId: string;
rawText: string;
summary: string;
initialTarget: NoteStatus;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
export function MoveStatusModal({ noteId, rawText, summary, initialTarget, onClose, onMoved }: Props): React.ReactElement {
const [reason, setReason] = useState('');
const [recommendation, setRecommendation] = useState<{ status: NoteStatus; rationale: string } | null>(null);
const [classifying, setClassifying] = useState(false);
async function move(status: NoteStatus): Promise<void> {
const trimmedReason = reason.trim() || null;
await inboxApi.setStatus(noteId, status, trimmedReason);
onMoved(status, trimmedReason);
}
async function classify(): Promise<void> {
setClassifying(true);
setRecommendation(null);
const r = await inboxApi.classifyStatus(noteId, reason);
setRecommendation({ status: r.recommended, rationale: r.rationale });
setClassifying(false);
}
return (
<div role="dialog" aria-label="이동" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100
}}>
<div style={{ background: '#fff', padding: 16, borderRadius: 8, minWidth: 400, maxWidth: 520 }}>
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}>메모 이동</h2>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="이동 사유 (선택사항)"
rows={2}
style={{ width: '100%', padding: 6, fontSize: 13 }}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button onClick={classify} disabled={classifying}>{classifying ? '분류 중...' : 'AI 자동 분류'}</button>
<button onClick={() => move('completed')}>완료</button>
<button onClick={() => move('archived')}>보관</button>
<button onClick={() => move('trashed')}>휴지통</button>
<button onClick={onClose} style={{ marginLeft: 'auto' }}>취소</button>
</div>
{recommendation && (
<div style={{ marginTop: 12, padding: 8, background: '#f0f8ff', borderRadius: 4, fontSize: 12 }}>
<div>AI 추천: <strong>{statusLabel(recommendation.status)}</strong></div>
<div style={{ marginTop: 4 }}>이유: {recommendation.rationale}</div>
<div style={{ marginTop: 8 }}>
<button onClick={() => move(recommendation.status)}>확정 ({statusLabel(recommendation.status)})</button>
</div>
</div>
)}
</div>
</div>
);
}
function statusLabel(s: NoteStatus): string {
switch (s) {
case 'completed': return '완료';
case 'archived': return '보관';
case 'trashed': return '휴지통';
case 'active': return '활성';
}
}
- Step 4: NoteCard.tsx 의 stub 제거 + 정식 import
NoteCard.tsx 의 임시 inline MoveStatusModal 제거 + 새 file import:
import { MoveStatusModal } from './MoveStatusModal';
- Step 5: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
Expected: 신규 2 test + NoteCard 회귀 정상.
- Step 6: commit
git add -A
git commit -m "feat(v029): MoveStatusModal — 사유 입력 + 4 status 버튼 + AI 자동 분류 placeholder"
Task 8: setStatus IPC + listByStatus 회귀
Files:
-
Modify:
src/main/ipc/inboxApi.ts(inbox:set-status핸들러) -
Modify:
src/preload/index.ts -
Modify:
src/shared/types.ts(InboxApi.setStatus 시그니처) -
Test:
tests/unit/inboxApi-setStatus.test.ts -
Step 1: failing test
// tests/unit/inboxApi-setStatus.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
const handlers: Record<string, Function> = {};
const mockSetStatus = vi.fn();
vi.mock('electron', () => ({
default: { ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } } }
}));
describe('inbox:set-status IPC', () => {
beforeEach(() => {
vi.clearAllMocks();
for (const k of Object.keys(handlers)) delete handlers[k];
});
it('forwards id/status/reason to repo.setStatus', async () => {
const { registerInboxApi } = await import('../../src/main/ipc/inboxApi');
registerInboxApi({
paths: { profileDir: '/p' },
repo: { setStatus: mockSetStatus, listByStatus: vi.fn(() => []) },
// 기타 deps stub
} as any);
const r = await handlers['inbox:set-status'](null, 'n1', 'completed', '결재 끝');
expect(r).toEqual({ ok: true });
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
});
it('rejects invalid status value', async () => {
const { registerInboxApi } = await import('../../src/main/ipc/inboxApi');
registerInboxApi({ paths: { profileDir: '/p' }, repo: { setStatus: mockSetStatus } } as any);
const r = await handlers['inbox:set-status'](null, 'n1', 'invalid', null);
expect(r.ok).toBe(false);
});
});
-
Step 2: 테스트 → fail
-
Step 3: 핸들러 추가
// src/main/ipc/inboxApi.ts — registerInboxApi 안:
const VALID_STATUS = new Set<NoteStatus>(['active', 'completed', 'archived', 'trashed']);
ipcMain.handle('inbox:set-status', async (_e, id: string, status: NoteStatus, reason: string | null) => {
if (!VALID_STATUS.has(status)) return { ok: false as const, reason: 'invalid status' as const };
deps.repo.setStatus(id, status, reason);
return { ok: true as const };
});
ipcMain.handle('inbox:list-by-status', async (_e, status: NoteStatus, opts: { limit?: number } = {}) => {
if (!VALID_STATUS.has(status)) return [];
return deps.repo.listByStatus(status, opts);
});
(Task 5 에서 inbox:list-by-status 가 이미 추가됐다면 중복 — 체크 후 한 곳만.)
- Step 4: types/preload/api.ts 갱신
// src/shared/types.ts
setStatus(id: string, status: NoteStatus, reason: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
preload + api wildcard re-export.
- Step 5: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 6: commit
git add -A
git commit -m "feat(v029): inbox:set-status + inbox:list-by-status IPC + types"
Phase 5: AI 자동 분류
Task 9: AiWorker.classifyStatus + IPC
Files:
-
Create:
src/main/ai/classifyStatus.ts -
Modify:
src/main/ai/AiWorker.ts(또는 별도 service) -
Modify:
src/main/ipc/inboxApi.ts -
Test:
tests/unit/classifyStatus.test.ts -
Step 1: failing test
// tests/unit/classifyStatus.test.ts
import { describe, it, expect, vi } from 'vitest';
import { classifyStatus } from '../../src/main/ai/classifyStatus';
describe('classifyStatus', () => {
it('parses recommended status and rationale from AI response', async () => {
const mockProvider = {
generate: vi.fn(async () => ({
rawText: '{"recommended":"completed","rationale":"처리됨 + 사용자 사유 결재 끝"}',
title: '', summary: '', tags: []
}))
};
const r = await classifyStatus({
provider: mockProvider as any,
rawText: 'X 미팅 결재',
summary: '결재 처리',
reason: '결재 끝'
});
expect(r.recommended).toBe('completed');
expect(r.rationale).toContain('처리됨');
});
it('falls back to "trashed" + rationale on AI parse failure', async () => {
const mockProvider = {
generate: vi.fn(async () => ({ rawText: 'invalid json', title: '', summary: '', tags: [] }))
};
const r = await classifyStatus({
provider: mockProvider as any,
rawText: 't', summary: '', reason: 'r'
});
expect(r.recommended).toBe('archived'); // safe default — 사용자 데이터 보존
expect(r.rationale).toMatch(/판단 실패/);
});
});
-
Step 2: 테스트 → fail
-
Step 3: classifyStatus.ts 작성
// src/main/ai/classifyStatus.ts
import type { InferenceProvider } from './InferenceProvider.js';
import type { NoteStatus } from '@shared/types';
interface Input {
provider: InferenceProvider;
rawText: string;
summary: string;
reason: string;
}
interface Output {
recommended: NoteStatus;
rationale: string;
}
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
가능한 status:
- completed: 작업이 끝났고 더 이상 행동 불필요
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
- trashed: 불필요, 의미 없는 메모
JSON 출력: { "recommended": "completed|archived|trashed", "rationale": "<한 문장>" }
메모 본문:
{{rawText}}
메모 요약:
{{summary}}
사용자 이동 사유:
{{reason}}`;
const VALID: NoteStatus[] = ['completed', 'archived', 'trashed'];
export async function classifyStatus(input: Input): Promise<Output> {
const prompt = PROMPT_TEMPLATE
.replace('{{rawText}}', input.rawText)
.replace('{{summary}}', input.summary)
.replace('{{reason}}', input.reason);
try {
const r = await input.provider.generate({
text: prompt,
todayKst: new Date().toISOString().slice(0, 10),
dueDateCandidates: [],
vocab: []
} as any);
const parsed = JSON.parse(r.rawText);
if (typeof parsed.recommended !== 'string' || !VALID.includes(parsed.recommended)) {
return { recommended: 'archived', rationale: '판단 실패 — 안전하게 보관 추천' };
}
return {
recommended: parsed.recommended as NoteStatus,
rationale: typeof parsed.rationale === 'string' ? parsed.rationale : ''
};
} catch {
return { recommended: 'archived', rationale: '판단 실패 — 안전하게 보관 추천' };
}
}
- Step 4: IPC 핸들러 추가
// src/main/ipc/inboxApi.ts
import { classifyStatus } from '../ai/classifyStatus.js';
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
const note = deps.repo.getById(id);
if (!note) return { recommended: 'archived' as const, rationale: '메모 없음' };
const provider = deps.providerHolder.get();
return await classifyStatus({
provider,
rawText: note.rawText,
summary: note.summary ?? '',
reason
});
});
types/preload/api wrapper:
classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>;
- Step 5: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 6: commit
git add -A
git commit -m "feat(v029): classifyStatus AI prompt + ai:classify-status IPC"
Task 10: MoveStatusModal — AI 자동 분류 UI 통합
본 task 는 Task 7 + Task 9 산출물 결합. Task 7 의 classify() 함수가 Task 9 의 IPC 호출 — 이미 mock 가 정상 시그니처 가정. 본 task 는 회귀 검증 + UX 디테일 (loading state / error message) 마무리.
Files:
-
Modify:
src/renderer/inbox/components/MoveStatusModal.tsx -
Modify:
tests/unit/MoveStatusModal.test.tsx -
Step 1: failing test (AI 추천 + 확정)
it('shows AI recommendation + confirm flow', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal noteId="n1" rawText="t" summary="" initialTarget="completed"
onClose={vi.fn()} onMoved={onMoved} />
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
await screen.findByText(/AI 추천/);
expect(screen.getByText(/완료/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /확정/ }));
await vi.waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
- Step 2: 기존 Task 7 코드 검증
이미 Task 7 의 MoveStatusModal 가 recommendation state + 확정 버튼 보유. 동작 검증만.
- Step 3: 회귀 + commit
npx vitest run tests/unit/MoveStatusModal.test.tsx
git add -A
git commit -m "test(v029): MoveStatusModal AI 자동 분류 + 확정 회귀 추가" || echo "no changes"
Phase 6: Onboarding wizard
Task 11: settings 의 onboarding_completed flag + Wizard component
Files:
-
Create:
src/renderer/inbox/components/OnboardingWizard.tsx -
Modify:
src/renderer/inbox/App.tsx(첫 launch 분기) -
Modify:
src/renderer/inbox/api.ts(getSettings / setOnboardingCompleted / setAiEnabled wrapper) -
Modify:
src/main/ipc/settingsApi.ts(해당 IPC 핸들러) -
Test:
tests/unit/OnboardingWizard.test.tsx -
Step 1: failing test
// tests/unit/OnboardingWizard.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
const mockSetAi = vi.fn(async () => ({ ok: true }));
const mockSetCompleted = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: { setAiEnabled: mockSetAi, setOnboardingCompleted: mockSetCompleted }
}));
import { OnboardingWizard } from '../../src/renderer/inbox/components/OnboardingWizard';
describe('OnboardingWizard', () => {
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
it('renders 3 options + 설치 가이드 link', () => {
render(<OnboardingWizard onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: /AI 자동 처리 사용/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /원문만 저장/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /나중에 설정/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /설치 가이드|ollama\.com/ })).toBeInTheDocument();
});
it('"AI 사용" → setAiEnabled(true) + setOnboardingCompleted(true) + onClose', async () => {
const onClose = vi.fn();
render(<OnboardingWizard onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /AI 자동 처리 사용/ }));
await vi.waitFor(() => {
expect(mockSetAi).toHaveBeenCalledWith(true);
expect(mockSetCompleted).toHaveBeenCalledWith(true);
expect(onClose).toHaveBeenCalled();
});
});
it('"원문만" → setAiEnabled(false) + setOnboardingCompleted(true)', async () => {
render(<OnboardingWizard onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /원문만 저장/ }));
await vi.waitFor(() => {
expect(mockSetAi).toHaveBeenCalledWith(false);
expect(mockSetCompleted).toHaveBeenCalledWith(true);
});
});
});
-
Step 2: 테스트 → fail
-
Step 3: OnboardingWizard.tsx 작성
// src/renderer/inbox/components/OnboardingWizard.tsx
import React from 'react';
import { inboxApi } from '../api.js';
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
async function choose(aiEnabled: boolean | null): Promise<void> {
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
await inboxApi.setOnboardingCompleted(true);
onClose();
}
return (
<div role="dialog" aria-label="시작 안내" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}}>
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 520 }}>
<h2 style={{ margin: '0 0 12px' }}>Inkling 사용 시작</h2>
<p style={{ fontSize: 14, lineHeight: 1.6, marginBottom: 12 }}>
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
</p>
<p style={{ fontSize: 13, marginBottom: 16 }}>
설치 가이드:
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={() => choose(true)}>AI 자동 처리 사용 (Ollama 필요)</button>
<button onClick={() => choose(false)}>원문만 저장 (AI 처리 안 함)</button>
<button onClick={() => choose(null)} style={{ marginTop: 4 }}>나중에 설정</button>
</div>
</div>
</div>
);
}
- Step 4: 테스트 + typecheck
npx vitest run tests/unit/OnboardingWizard.test.tsx
npm run typecheck
- Step 5: commit
git add src/renderer/inbox/components/OnboardingWizard.tsx tests/unit/OnboardingWizard.test.tsx
git commit -m "feat(v029): OnboardingWizard 3 옵션 + 설치 가이드 link"
Task 12: App.tsx 첫 launch 분기 + IPC + settings wrapper
Files:
-
Modify:
src/renderer/inbox/App.tsx -
Modify:
src/main/ipc/settingsApi.ts(set-ai-enabled / set-onboarding-completed / get-settings) -
Modify:
src/preload/index.ts -
Modify:
src/renderer/inbox/api.ts(wrapper) -
Modify:
src/shared/types.ts(시그니처) -
Step 1: App.tsx 갱신
// App 안:
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
useEffect(() => {
void (async () => {
const settings = await inboxApi.getSettings();
setShowOnboarding(!settings.onboarding_completed);
})();
}, []);
if (showOnboarding === null) return <></>; // 또는 로딩
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
// 이후 기존 inbox 흐름
- Step 2: settingsApi 핸들러 + wrapper
// src/main/ipc/settingsApi.ts
ipcMain.handle('settings:get', async () => deps.settings.getAll());
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
await deps.settings.set('ai_enabled', enabled);
return { ok: true as const };
});
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
await deps.settings.set('onboarding_completed', completed);
return { ok: true as const };
});
types.ts InboxApi:
getSettings(): Promise<Settings>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
preload + api wrapper.
- Step 3: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 4: commit
git add -A
git commit -m "feat(v029): App.tsx 첫 launch onboarding + settings:* IPC (ai-enabled / onboarding-completed)"
Phase 7: AI off NoteCard fallback + Banner 비활성
Task 13: NoteCard ai_status='disabled' fallback
Files:
-
Modify:
src/renderer/inbox/components/NoteCard.tsx -
Modify:
tests/unit/NoteCard.test.tsx -
Step 1: failing test
it('ai_status=disabled: title fallback to raw_text first line, summary/tags hidden', () => {
const disabledNote = { ...baseNote, aiStatus: 'disabled', title: '', summary: 'should-not-show', tags: ['t1'], rawText: '첫 줄 본문\n둘째 줄 본문' };
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
expect(screen.getByText('첫 줄 본문')).toBeInTheDocument();
expect(screen.queryByText('should-not-show')).toBeNull();
expect(screen.queryByText('t1')).toBeNull();
});
-
Step 2: 테스트 → fail
-
Step 3: NoteCard 갱신
// NoteCard 안:
const isDisabled = local.aiStatus === 'disabled';
const displayTitle = isDisabled
? (local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)')
: (local.title?.trim() || local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)');
// title 표시:
<h3>{displayTitle}</h3>
// summary / tags 표시 — isDisabled 면 hide:
{!isDisabled && local.summary && <p>{local.summary}</p>}
{!isDisabled && local.tags.length > 0 && (
<div>{local.tags.map(t => <span key={t}>{t}</span>)}</div>
)}
- Step 4: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 5: commit
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v029): NoteCard ai_status='disabled' fallback (raw_text 첫 줄 + summary/tags hide)"
Task 14: OllamaBanner / FailedBanner / HealthChecker ai_enabled false 시 비활성
Files:
-
Modify:
src/renderer/inbox/components/OllamaBanner.tsx -
Modify:
src/renderer/inbox/components/FailedBanner.tsx -
Modify:
src/main/health/HealthChecker.ts -
Modify:
src/renderer/inbox/store.ts(settings load → state 에 ai_enabled 보관) -
Modify:
tests/unit/OllamaBanner.test.tsx/FailedBanner.test.tsx -
Step 1: failing test (Banner)
// tests/unit/OllamaBanner.test.tsx (또는 신규)
it('renders nothing when ai_enabled=false', () => {
useInbox.setState({ ai_enabled: false, ollamaStatus: { ok: false, reason: 'unreachable' } });
const { container } = render(<OllamaBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders banner when ai_enabled=true and ollama not ok', () => {
useInbox.setState({ ai_enabled: true, ollamaStatus: { ok: false, reason: 'unreachable' } });
render(<OllamaBanner />);
expect(screen.getByText(/Ollama/)).toBeInTheDocument();
});
(FailedBanner 동일 패턴)
-
Step 2: 테스트 → fail
-
Step 3: store + Banner 갱신
store 에 ai_enabled field 추가:
// store.ts
ai_enabled: true, // initial — settings 로드 시 갱신
async loadInitial() {
const settings = await inboxApi.getSettings();
set({ ai_enabled: settings.ai_enabled });
// ... 기존
}
OllamaBanner / FailedBanner 갱신:
// OllamaBanner.tsx
const aiEnabled = useInbox(s => s.ai_enabled);
const status = useInbox(s => s.ollamaStatus);
if (!aiEnabled) return null;
if (status.ok) return null;
// 기존 banner render
// FailedBanner.tsx — 동일 패턴
const aiEnabled = useInbox(s => s.ai_enabled);
const failedCount = useInbox(s => s.failedCount);
if (!aiEnabled) return null;
if (failedCount === 0) return null;
// 기존 banner render
HealthChecker — ai_enabled=false 시 polling 중단:
// src/main/health/HealthChecker.ts
private async tick(): Promise<void> {
const aiEnabled = await this.deps.settings.get('ai_enabled', true);
if (!aiEnabled) return; // skip
// 기존 healthCheck 흐름
}
(deps 에 settings 추가 필요. main/index.ts wiring 갱신.)
- Step 4: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 5: commit
git add -A
git commit -m "feat(v029): Banner + HealthChecker ai_enabled=false 시 비활성"
Phase 8: 설정 페이지 토글 + disabled 메모 처리
Task 15: AiProviderSection 의 "AI 자동 처리 사용" 토글
Files:
-
Modify:
src/renderer/inbox/components/settings/AiProviderSection.tsx -
Modify:
tests/unit/AiProviderSection.test.tsx -
Step 1: failing test
it('renders AI 자동 처리 toggle (default true)', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true, /* ... */ } as any);
render(<AiProviderSection />);
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
expect((toggle as HTMLInputElement).checked).toBe(true);
});
it('toggling calls setAiEnabled', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as any);
render(<AiProviderSection />);
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
fireEvent.click(toggle);
await vi.waitFor(() => expect(inboxApi.setAiEnabled).toHaveBeenCalledWith(false));
});
-
Step 2: 테스트 → fail
-
Step 3: AiProviderSection 갱신
// AiProviderSection.tsx 상단:
const [aiEnabled, setAiEnabled] = useState<boolean | null>(null);
const [disabledCount, setDisabledCount] = useState(0);
useEffect(() => {
void (async () => {
const s = await inboxApi.getSettings();
setAiEnabled(s.ai_enabled);
if (s.ai_enabled) {
const c = await inboxApi.getDisabledCount();
setDisabledCount(c);
}
})();
}, []);
async function onToggleAi(checked: boolean): Promise<void> {
await inboxApi.setAiEnabled(checked);
setAiEnabled(checked);
}
// JSX (기존 endpoint/model 위에):
{aiEnabled !== null && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<input type="checkbox" checked={aiEnabled} onChange={e => onToggleAi(e.target.checked)} />
AI 자동 처리 사용
</label>
)}
{aiEnabled === false && (
<p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
원문만 저장 모드. 메모의 제목/요약/태그가 자동 생성되지 않습니다.
<br />
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">Ollama 설치 가이드</a>
</p>
)}
- Step 4: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 5: commit
git add src/renderer/inbox/components/settings/AiProviderSection.tsx tests/unit/AiProviderSection.test.tsx
git commit -m "feat(v029): AiProviderSection AI 자동 처리 토글 + disabled count"
Task 16: ON 전환 후 "기존 disabled 메모 N건 처리" 버튼
Files:
-
Modify:
src/renderer/inbox/components/settings/AiProviderSection.tsx -
Modify:
src/main/ipc/inboxApi.ts(inbox:enqueue-disabledIPC) -
Modify:
src/main/repository/NoteRepository.ts(requeueDisabled()메서드) -
Test:
tests/unit/NoteRepository.test.ts -
Step 1: failing test (repo)
describe('NoteRepository.requeueDisabled', () => {
it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
const id = repo.create({ rawText: 't', aiStatus: 'disabled', now: new Date() }).id;
const count = repo.requeueDisabled();
expect(count).toBe(1);
const note = repo.getById(id);
expect(note?.aiStatus).toBe('pending');
const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
expect(job).toBeDefined();
});
});
-
Step 2: 테스트 → fail
-
Step 3: NoteRepository.requeueDisabled 추가
requeueDisabled(now: Date = new Date()): number {
const tx = this.db.transaction(() => {
const ts = now.toISOString();
const targets = this.db.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`).all() as Array<{ id: string }>;
for (const { id } of targets) {
this.db.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`).run(ts, id);
this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, ts);
}
return targets.length;
});
return tx();
}
- Step 4: IPC 핸들러
// src/main/ipc/inboxApi.ts
ipcMain.handle('inbox:enqueue-disabled', async () => {
const count = deps.repo.requeueDisabled();
// 큐에 들어갔으니 worker.notify
if (count > 0) await deps.worker.notify();
return { count };
});
ipcMain.handle('inbox:get-disabled-count', async () => {
return deps.repo.countByAiStatus('disabled');
});
(NoteRepository 에 countByAiStatus 도 신규 — 1줄 추가.)
- Step 5: AiProviderSection UI 갱신
// 토글 ON 일 때 + disabledCount > 0 일 때:
{aiEnabled === true && disabledCount > 0 && (
<div style={{ padding: 8, background: '#fffbe5', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
원문만 저장된 메모 {disabledCount}건이 있습니다.
<button onClick={async () => {
const r = await inboxApi.enqueueDisabled();
setDisabledCount(0);
// 사용자 알림 (toast 또는 inline)
}} style={{ marginLeft: 8 }}>
지금 모두 처리
</button>
</div>
)}
types/preload/api wrapper:
enqueueDisabled(): Promise<{ count: number }>;
getDisabledCount(): Promise<number>;
- Step 6: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
- Step 7: commit
git add -A
git commit -m "feat(v029): requeueDisabled + enqueue-disabled IPC + AiProviderSection 처리 버튼"
Phase 9: verification + version bump
Task 17: 회귀 + dogfood F17/F18/F23 promoted + version 0.2.9 bump
Files:
-
Modify:
docs/superpowers/specs/2026-04-25-dogfood-feedback.md(F17/F18/F23 promoted) -
Modify:
package.json(version 0.2.8 → 0.2.9) -
Step 1: 단위 + typecheck + e2e 일괄
npm run rebuild:node
npm test 2>&1 | tail -5
npm run typecheck 2>&1 | tail -3
npm run rebuild:electron
npm run test:e2e 2>&1 | tail -10
Expected: 단위 472 → 약 510 (+38: m004 4 + repo 4 + capture 2 + view 3 + tabs 2 + Modal 2-3 + IPC 2-3 + classify 2 + Onboarding 3 + NoteCard fallback 1 + Banner 2-3 + AI 토글 2 + requeue 1 + 기타). typecheck 0. e2e 1/1.
- Step 2: dogfood-feedback.md F17 entry 갱신
## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
진행 상태:
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. status 4분기 (active/completed/archived/trashed) + AI 자동 분류 버튼.
- Step 3: F18 / F23 entry 갱신
## F18. 메모 휴지통/보관 이동 시 사유 입력 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
각 진행 상태도 🚀 promoted → v0.2.9 Cut B 로 갱신.
- Step 4: package.json version
"version": "0.2.9"
- Step 5: commit
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
git commit -m "chore(release): v0.2.9 — Cut B (status 4분기 + 사유 + Ollama-less)"
Self-Review
Spec coverage:
| Spec 섹션 | task |
|---|---|
| §3 F17 schema (m004 + status enum) | Task 1 |
| §3-2 NoteRepository setStatus + listByStatus | Task 2 |
| §3-3 4탭 UI + view enum | Task 4, 5 |
| §3-4 NoteCard 액션 메뉴 | Task 6 |
| §3-5 AI 자동 분류 prompt + UI | Task 9, 10 |
| §3-6 IPC inbox:set-status / ai:classify-status | Task 8, 9 |
| §4 F18 자유 텍스트 사유 (move_reason 컬럼) | Task 1 (schema) + Task 7 (UI) |
| §5-1 ai_status 'disabled' enum | Task 3 |
| §5-2 Onboarding wizard 3 옵션 + 설치 가이드 | Task 11, 12 |
| §5-3 CaptureService aiEnabled 분기 | Task 3 |
| §5-4 NoteCard fallback (title=raw_text 첫 줄) | Task 13 |
| §5-5 Banner 비활성 | Task 14 |
| §5-6 설정 페이지 토글 | Task 15 |
| §5-7 ON 전환 후 disabled 처리 | Task 16 |
| §6 테스트 472 → ~510 | 각 task |
모든 spec 요구가 task 매핑됨.
Placeholder scan: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태.
Type consistency:
NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'— Task 2, 8, 9 동일 사용setStatus(id, status, reason)시그니처 — repo (Task 2), IPC (Task 8), MoveStatusModal (Task 7), App.tsx 일관classifyStatus({recommended, rationale})— Task 9 정의, Task 10 UI 사용 일관ai_enabled,onboarding_completed— settings schema (Task 3) + Wizard (Task 11) + Banner (Task 14) + AiProviderSection (Task 15) 일관requeueDisabled(): number— Task 16 정의, AiProviderSection UI 사용 일관
이슈 없음.
Execution Handoff
Plan 작성 완료, docs/superpowers/plans/2026-05-09-v029-cut-b.md 저장.
두 가지 실행 옵션:
1. Subagent-Driven (recommended) — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration
2. Inline Execution — 본 세션에서 task 일괄 실행 + checkpoint 마다 review
어느 쪽?