Files
inkling/docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md
th-kim0823 dbc0acbaf5 docs(plan): v0.4 notebooks + lifecycle implementation plan
20 task — m008 마이그레이션 → NotebookRepository → AI prompt + schema +
AiWorker 매칭 → store (notebooks + promotionCandidate) → Sidebar /
NotebookList / NotebookCreateModal / PromotionBanner → App 통합 (Cmd+B,
헤더 3탭, archived 제거) → search scope → CHANGELOG v0.4.0.

각 task TDD step (실패 test → 구현 → 통과 → commit). 모든 step 에 실제
code block 포함, placeholder 없음.

sync (Cut E) 와의 frontmatter notebook 통합은 본 plan 에서 deferred —
v0.4 본체 머지 후 별도 작업.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:56:16 +09:00

73 KiB
Raw Blame History

v0.4 Notebooks + Lifecycle Simplification Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: notebook 카테고리 도입 + lifecycle 3분기 단순화 + AI 의 자동 fit 매칭 / promotion 제안 구현.

Architecture: 단일 SQLite DB 안에 notebooks 테이블 + notes.notebook_id 컬럼 추가. archived status 제거 (마이그레이션 시 completed 로 합침). 매 capture 시 AI 가 notebooks 목록을 보고 best-fit 자동 배치, 새 notebook 생성은 rule-based promotion (tag 3건 이상 누적) 으로만 제안.

Tech Stack: TypeScript / better-sqlite3 / Zod / electron-vite / React / vitest

선행 spec: docs/superpowers/specs/2026-05-14-v04-notebooks-lifecycle-design.md


File Structure

Create

  • src/main/db/migrations/m008_notebooks.ts — schema 변경 + default notebook + archived 정리
  • src/main/repository/NotebookRepository.ts — CRUD + count + RESTRICT delete + promotion query helper
  • src/main/ipc/notebookApi.ts — IPC 핸들러
  • src/renderer/inbox/components/Sidebar.tsx — 좌측 패널 root
  • src/renderer/inbox/components/NotebookList.tsx — notebook 목록 + count badge
  • src/renderer/inbox/components/NotebookCreateModal.tsx — name + color picker
  • src/renderer/inbox/components/PromotionBanner.tsx — Inbox 상단 banner
  • tests/unit/m008.test.ts
  • tests/unit/NotebookRepository.test.ts
  • tests/unit/notebookApi.test.ts
  • tests/unit/Sidebar.test.tsx
  • tests/unit/NotebookList.test.tsx
  • tests/unit/PromotionBanner.test.tsx
  • tests/unit/store.notebook.test.ts

Modify

  • src/main/db/migrations/index.ts — m008 등록
  • src/main/repository/NoteRepository.ts — notebook_id 필터, findPromotionCandidates, hydrate 갱신
  • src/main/ipc/inboxApi.ts — list/search/counts/list-by-status 에 notebookId 옵션
  • src/main/ai/prompt.ts — buildPrompt 에 notebooks 목록 주입
  • src/main/ai/schema.ts — RawResponseSchema 에 notebook_match 필드, parseAiResponse 에 notebookMatch 반환
  • src/main/ai/AiWorker.ts — settings.getDefaultNotebookId / matchNotebook 처리
  • src/main/ai/InferenceProvider.ts — GenerateInput 에 notebooks 추가
  • src/main/ai/LocalOllamaProvider.ts — generate 가 notebooks input 전달
  • src/main/services/SettingsService.ts — sidebar_visible / sidebar_width / promotion_dismissed_tags / promotion_snoozed_until_ms
  • src/shared/types.ts — Notebook type, NoteStatus 에서 archived 제거, InboxApi/SettingsApi 인터페이스 확장
  • src/preload/index.ts — notebookApi exposure
  • src/renderer/inbox/store.ts — notebooks state + actions + promotionCandidate
  • src/renderer/inbox/api.ts — notebookApi facade
  • src/renderer/inbox/App.tsx — Sidebar 통합, 헤더 3탭, Cmd+B 단축키
  • src/renderer/inbox/components/MoveStatusModal.tsx — archived 옵션 제거
  • src/renderer/inbox/components/NoteCard.tsx — notebook chip + 1-click move
  • src/main/index.ts — NotebookRepository / notebookApi 등록
  • CHANGELOG.md

Task 1: m008 마이그레이션 — schema + default notebook + archived 정리

Files:

  • Create: src/main/db/migrations/m008_notebooks.ts

  • Modify: src/main/db/migrations/index.ts

  • Test: tests/unit/m008.test.ts

  • Step 1: 마이그레이션 파일 신설

src/main/db/migrations/m008_notebooks.ts:

// v8: notebooks 테이블 + notes.notebook_id (FK) + archived → completed 정리.
// CHECK 제약 없는 status 컬럼이라 SQL 변경은 데이터 정리만, enum 단속은 TypeScript 측에서.
import type Database from 'better-sqlite3';
import { v7 as uuidv7 } from 'uuid';

export const version = 8;

const DEFAULT_NOTEBOOK_NAME = '기본';

export function up(db: Database.Database): void {
  const now = new Date().toISOString();
  const defaultId = uuidv7();

  db.exec(`
    CREATE TABLE notebooks (
      id         TEXT PRIMARY KEY,
      name       TEXT NOT NULL,
      color      TEXT,
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL
    );
    CREATE UNIQUE INDEX idx_notebooks_name ON notebooks(name);

    ALTER TABLE notes ADD COLUMN notebook_id TEXT
      REFERENCES notebooks(id) ON DELETE RESTRICT;
    CREATE INDEX idx_notes_notebook_id ON notes(notebook_id);
  `);

  db.prepare(
    `INSERT INTO notebooks (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`
  ).run(defaultId, DEFAULT_NOTEBOOK_NAME, now, now);

  db.prepare(`UPDATE notes SET notebook_id = ? WHERE notebook_id IS NULL`).run(defaultId);

  // archived 잔류 (dogfood 0건 확인됐지만 defensive) → completed 로 통합.
  db.prepare(`UPDATE notes SET status='completed' WHERE status='archived'`).run();
}
  • Step 2: index.ts 에 m008 등록

src/main/db/migrations/index.ts — import 추가 + migrations 배열 끝에 m008 추가:

import * as m008 from './m008_notebooks.js';
// ...
const migrations = [m001, m002, m003, m004, m005, m006, m007, m008];
  • Step 3: 실패하는 test 작성

tests/unit/m008.test.ts:

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';

describe('m008 notebooks migration', () => {
  let db: Database.Database;
  beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); });
  afterEach(() => { db.close(); });

  it('fresh DB: notebooks 테이블 + default "기본" notebook 생성', () => {
    runMigrations(db);
    const row = db.prepare(`SELECT name FROM notebooks`).get() as { name: string };
    expect(row.name).toBe('기본');
  });

  it('기존 notes 가 default notebook 으로 마이그레이션', () => {
    runMigrations(db);
    const defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
    db.prepare(`INSERT INTO notes(id,raw_text,ai_status,created_at,updated_at,status) VALUES('n1','t','pending','2026-05-14','2026-05-14','active')`).run();
    // notebook_id 가 NOT NULL 직접 제약은 아니지만, m008 의 UPDATE 가 NULL 을 모두 채움 — 신규 insert 는 명시적으로 채워야 함을 테스트하려면 NotebookRepository task 에서.
    const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='n1'`).get() as { notebook_id: string | null };
    // fresh insert 에는 default 가 자동 들어가지 않으므로 NULL — 사후 UPDATE 책임은 NoteRepository.create 가짐 (Task 5).
    expect(r.notebook_id).toBeNull();
  });

  it('archived 잔류 노트가 있다면 completed 로 통합', () => {
    // m007 까지 적용된 상태에서 archived 노트 삽입 후 m008 적용 검증 — runMigrations 가 모든 미적용 마이그레이션을 한 번에 돌리므로, 본 테스트는 m008 만 분리 적용 패턴 사용.
    // 간단히 fresh DB 에서 검증 — archived 통합 자체는 노트가 없어도 SQL 자체가 통과해야 함.
    runMigrations(db);
    expect(() => db.prepare(`SELECT COUNT(*) FROM notes WHERE status='archived'`).get()).not.toThrow();
  });

  it('UNIQUE index 가 같은 이름 중복 INSERT 거부', () => {
    runMigrations(db);
    expect(() =>
      db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('x','기본','t','t')`).run()
    ).toThrow();
  });
});
  • Step 4: 테스트 실패 확인

Run: npx vitest run tests/unit/m008.test.ts Expected: FAIL (m008 import 못 찾음 또는 migrations 배열에 없음)

  • Step 5: Step 1, 2 의 코드가 실제 파일에 들어가 있는지 검증 후 재실행

Run: npx vitest run tests/unit/m008.test.ts Expected: 4 passed

  • Step 6: commit
git add src/main/db/migrations/m008_notebooks.ts src/main/db/migrations/index.ts tests/unit/m008.test.ts
git commit -m "feat(db): m008 — notebooks 테이블 + notes.notebook_id + archived 정리"

Task 2: NotebookRepository — type 정의 + CRUD + count

Files:

  • Modify: src/shared/types.ts

  • Create: src/main/repository/NotebookRepository.ts

  • Test: tests/unit/NotebookRepository.test.ts

  • Step 1: Notebook type 추가 (shared/types.ts)

src/shared/types.ts 의 적절한 위치 (Note 인터페이스 근처) 에 추가:

export interface Notebook {
  id: string;
  name: string;
  color: string | null;
  createdAt: string;
  updatedAt: string;
  noteCount: number;  // status='active' 노트만 카운트
}

같은 파일의 NoteStatus 정의에서 'archived' 제거:

// 변경 전 (참조용):
// export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
// 변경 후:
export type NoteStatus = 'active' | 'completed' | 'trashed';
  • Step 2: Note 인터페이스에 notebookId 추가

src/shared/types.tsNote 인터페이스에 필드 추가:

export interface Note {
  // ... 기존 필드 ...
  notebookId: string;  // m008 마이그레이션 보장 — NOT NULL 동치
}
  • Step 3: 실패하는 test 작성

tests/unit/NotebookRepository.test.ts:

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';
import { NotebookRepository } from '../../src/main/repository/NotebookRepository.js';

describe('NotebookRepository', () => {
  let db: Database.Database;
  let repo: NotebookRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    db.pragma('foreign_keys = ON');
    runMigrations(db);
    repo = new NotebookRepository(db);
  });
  afterEach(() => { db.close(); });

  it('list: 기본 notebook 1개 + noteCount 0', () => {
    const all = repo.list();
    expect(all).toHaveLength(1);
    expect(all[0]!.name).toBe('기본');
    expect(all[0]!.noteCount).toBe(0);
  });

  it('create: 새 notebook 추가', () => {
    const nb = repo.create({ name: '회사', color: '#0a4b80' });
    expect(nb.name).toBe('회사');
    expect(nb.color).toBe('#0a4b80');
    expect(repo.list()).toHaveLength(2);
  });

  it('create: 같은 이름 두 번이면 throw', () => {
    repo.create({ name: '회사' });
    expect(() => repo.create({ name: '회사' })).toThrow();
  });

  it('rename: 이름 변경', () => {
    const nb = repo.create({ name: '회사' });
    repo.rename(nb.id, '워크');
    const after = repo.findById(nb.id);
    expect(after?.name).toBe('워크');
  });

  it('delete: 메모 없으면 OK', () => {
    const nb = repo.create({ name: '회사' });
    const r = repo.delete(nb.id);
    expect(r.ok).toBe(true);
    expect(repo.findById(nb.id)).toBeNull();
  });

  it('delete: 메모 있으면 RESTRICT — ok:false', () => {
    const nb = repo.create({ name: '회사' });
    const ts = '2026-05-14T00:00:00Z';
    db.prepare(
      `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id)
       VALUES('n1','t','pending',?,?,'active',?)`
    ).run(ts, ts, nb.id);
    const r = repo.delete(nb.id);
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.reason).toBe('has_notes');
  });

  it('noteCount: status="active" 만 카운트 (completed/trashed 제외)', () => {
    const nb = repo.create({ name: '회사' });
    const ts = '2026-05-14T00:00:00Z';
    const insert = db.prepare(
      `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id)
       VALUES(?,?,?,?,?,?,?)`
    );
    insert.run('n1','t','done',ts,ts,'active',nb.id);
    insert.run('n2','t','done',ts,ts,'completed',nb.id);
    insert.run('n3','t','done',ts,ts,'trashed',nb.id);
    const found = repo.findById(nb.id);
    expect(found?.noteCount).toBe(1);
  });
});
  • Step 4: NotebookRepository 구현

src/main/repository/NotebookRepository.ts:

import type Database from 'better-sqlite3';
import { v7 as uuidv7 } from 'uuid';
import type { Notebook } from '@shared/types';

export class NotebookRepository {
  constructor(private db: Database.Database) {}

  list(): Notebook[] {
    const rows = this.db.prepare(
      `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
              (SELECT COUNT(*) FROM notes n
                WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
         FROM notebooks nb
        ORDER BY nb.name ASC`
    ).all() as Array<Record<string, unknown>>;
    return rows.map((r) => this.hydrate(r));
  }

  findById(id: string): Notebook | null {
    const r = this.db.prepare(
      `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
              (SELECT COUNT(*) FROM notes n
                WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
         FROM notebooks nb WHERE nb.id = ?`
    ).get(id) as Record<string, unknown> | undefined;
    return r ? this.hydrate(r) : null;
  }

  /** UNIQUE name violation 시 better-sqlite3 가 throw — caller 가 ok:false 변환. */
  create(input: { name: string; color?: string | null }): Notebook {
    const id = uuidv7();
    const now = new Date().toISOString();
    this.db.prepare(
      `INSERT INTO notebooks(id, name, color, created_at, updated_at) VALUES(?,?,?,?,?)`
    ).run(id, input.name, input.color ?? null, now, now);
    return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 };
  }

  rename(id: string, name: string): void {
    const now = new Date().toISOString();
    this.db.prepare(`UPDATE notebooks SET name=?, updated_at=? WHERE id=?`).run(name, now, id);
  }

  setColor(id: string, color: string | null): void {
    const now = new Date().toISOString();
    this.db.prepare(`UPDATE notebooks SET color=?, updated_at=? WHERE id=?`).run(color, now, id);
  }

  /** FK RESTRICT 가 메모 잔류 시 throw — ok:false 로 변환. */
  delete(id: string): { ok: true } | { ok: false; reason: 'has_notes' | 'not_found' } {
    const exists = this.db.prepare(`SELECT 1 FROM notebooks WHERE id=?`).get(id);
    if (!exists) return { ok: false, reason: 'not_found' };
    try {
      this.db.prepare(`DELETE FROM notebooks WHERE id=?`).run(id);
      return { ok: true };
    } catch (e) {
      const msg = (e as Error).message;
      if (msg.includes('FOREIGN KEY')) return { ok: false, reason: 'has_notes' };
      throw e;
    }
  }

  /** notes.notebook_id 갱신만 (status 등은 보존). */
  moveNote(noteId: string, notebookId: string): void {
    this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
      .run(notebookId, new Date().toISOString(), noteId);
  }

  private hydrate(r: Record<string, unknown>): Notebook {
    return {
      id: r.id as string,
      name: r.name as string,
      color: (r.color as string | null) ?? null,
      createdAt: r.created_at as string,
      updatedAt: r.updated_at as string,
      noteCount: r.note_count as number
    };
  }
}
  • Step 5: 테스트 실패 확인 → 통과 확인
npx vitest run tests/unit/NotebookRepository.test.ts

Expected: 7 passed

  • Step 6: commit
git add src/main/repository/NotebookRepository.ts src/shared/types.ts tests/unit/NotebookRepository.test.ts
git commit -m "feat(notebook): NotebookRepository CRUD + noteCount + RESTRICT delete"

Task 3: NoteRepository 변경 — notebook_id hydrate + 생성 시 default 보장

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Test: tests/unit/NoteRepository.test.ts

  • Step 1: 실패하는 test 추가 (NoteRepository.test.ts 의 적절한 describe 안)

tests/unit/NoteRepository.test.ts 끝에 새 describe 추가:

describe('NoteRepository.create with notebook', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let defaultId: string;
  beforeEach(() => {
    db = new Database(':memory:');
    db.pragma('foreign_keys = ON');
    runMigrations(db);
    repo = new NoteRepository(db);
    defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
  });

  it('notebook_id 미지정 시 default notebook 으로 들어감', () => {
    const { id } = repo.create({ rawText: 'hello' });
    const r = repo.findById(id);
    expect(r?.notebookId).toBe(defaultId);
  });

  it('notebook_id 지정 시 그 값 보존', () => {
    const insert = db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`);
    insert.run('nb-other', '회사', '2026-05-14', '2026-05-14');
    const { id } = repo.create({ rawText: 'hi', notebookId: 'nb-other' });
    expect(repo.findById(id)?.notebookId).toBe('nb-other');
  });
});
  • Step 2: CreateNoteInput 에 notebookId 추가 + create 구현

src/main/repository/NoteRepository.tsCreateNoteInput:

export interface CreateNoteInput {
  rawText: string;
  aiStatus?: AiStatus;
  notebookId?: string;  // 미지정 시 default notebook
}

create() 메서드에 notebook_id 채우는 로직 추가. 구현:

create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
  const id = uuidv7();
  const ts = now.toISOString();
  const aiStatus: AiStatus = input.aiStatus ?? 'pending';
  const notebookId = input.notebookId ?? this.getDefaultNotebookId();
  const tx = this.db.transaction(() => {
    this.db
      .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, notebook_id)
                VALUES (?, ?, ?, ?, ?, ?)`)
      .run(id, input.rawText, aiStatus, ts, ts, notebookId);
    this.db
      .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
                VALUES (?, ?, ?, 'capture')`)
      .run(id, input.rawText, ts);
    if (aiStatus === 'pending') {
      this.db
        .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
                  VALUES (?, 0, ?)`)
        .run(id, ts);
    }
  });
  tx();
  return { id };
}

/** 가장 오래된 notebook 을 default 로 — m008 가 보장. cache 안 함 (per-write 1 query). */
private getDefaultNotebookId(): string {
  const r = this.db.prepare(`SELECT id FROM notebooks ORDER BY created_at ASC LIMIT 1`).get() as { id: string };
  return r.id;
}
  • Step 3: hydrate 에 notebookId 추가

NoteRepository.hydrate (Note 객체 생성하는 메서드) 에 1 줄:

notebookId: row.notebook_id as string,

각 SELECT 쿼리도 notebook_id 가 row 에 포함되도록 확인 (SELECT * 라면 자동 포함).

  • Step 4: 테스트 통과 확인
npx vitest run tests/unit/NoteRepository.test.ts

Expected: all passed (기존 + 신규 2건)

  • Step 5: commit
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(notes): notebook_id 필드 + create 시 default notebook 보장"

Task 4: NoteRepository 의 list/search/counts 에 notebookId 옵션

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Test: tests/unit/NoteRepository.test.ts

  • Step 1: 실패하는 test 추가

tests/unit/NoteRepository.test.ts:

describe('NoteRepository.list / countByStatus with notebookId', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let nbA: string, nbB: string;
  beforeEach(() => {
    db = new Database(':memory:');
    db.pragma('foreign_keys = ON');
    runMigrations(db);
    repo = new NoteRepository(db);
    nbA = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
    nbB = 'nb-b';
    db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`).run(nbB,'회사','t','t');
  });

  it('list 가 notebookId 필터로 노트 분리', () => {
    repo.create({ rawText: 'in-default' });
    repo.create({ rawText: 'in-B', notebookId: nbB });
    expect(repo.list({ limit: 10, notebookId: nbA }).map((n) => n.rawText)).toEqual(['in-default']);
    expect(repo.list({ limit: 10, notebookId: nbB }).map((n) => n.rawText)).toEqual(['in-B']);
  });

  it('countByStatus(notebookId) — 각 notebook 의 active 갯수', () => {
    repo.create({ rawText: 'a1' });
    repo.create({ rawText: 'a2' });
    repo.create({ rawText: 'b1', notebookId: nbB });
    expect(repo.countByStatus('active', { notebookId: nbA })).toBe(2);
    expect(repo.countByStatus('active', { notebookId: nbB })).toBe(1);
  });
});
  • Step 2: list / listByStatus / countByStatus signature 확장

NoteRepository.list:

list(opts: { limit: number; cursor?: string; notebookId?: string }): Note[] {
  const limit = Math.max(1, Math.min(200, opts.limit));
  const params: unknown[] = [];
  let sql = `SELECT * FROM notes WHERE deleted_at IS NULL`;
  if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
  if (opts.cursor) { sql += ` AND created_at < ?`; params.push(opts.cursor); }
  sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`;
  params.push(limit);
  const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
  return rows.map((r) => this.hydrate(r));
}

listByStatus:

listByStatus(status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}): Note[] {
  const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
  const params: unknown[] = [status];
  let sql = `SELECT * FROM notes WHERE status = ?`;
  if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
  sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`;
  params.push(limit);
  const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
  return rows.map((r) => this.hydrate(r));
}

countByStatus 도 signature 확장:

countByStatus(status: NoteStatus, opts: { notebookId?: string } = {}): number {
  const params: unknown[] = [status];
  let sql = `SELECT COUNT(*) as c FROM notes WHERE status = ?`;
  if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
  const r = this.db.prepare(sql).get(...params) as { c: number };
  return r.c;
}

같은 패턴으로 search 도 옵션 확장 — search(query, opts: { limit?, status?, notebookId? }):

search(query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}): Note[] {
  // 기존 본문 + WHERE 절에 notebookId 추가 한 줄
  // ... (기존 sanitizeFtsQuery 등 보존) ...
  if (opts.notebookId) { sql += ` AND n.notebook_id = ?`; params.push(opts.notebookId); }
  // ...
}
  • Step 3: 테스트 통과 확인 + 기존 호출자 회귀 점검
npx vitest run tests/unit/NoteRepository.test.ts

Expected: all passed. 기존 호출자 (notebookId 미지정) 가 회귀 없음 확인.

  • Step 4: commit
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(notes): list/listByStatus/countByStatus/search 에 notebookId 옵션"

Task 5: NoteRepository.findPromotionCandidates — tag 3건 이상 default notebook 클러스터

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Test: tests/unit/NoteRepository.test.ts

  • Step 1: 실패하는 test 추가

describe('NoteRepository.findPromotionCandidates', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let defaultId: string;
  beforeEach(() => {
    db = new Database(':memory:');
    db.pragma('foreign_keys = ON');
    runMigrations(db);
    repo = new NoteRepository(db);
    defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
  });

  function insertWithTag(rawText: string, tagName: string): string {
    const { id } = repo.create({ rawText });
    repo.updateAiResult(id, { title: rawText, summary: 'a\nb\nc', tags: [tagName], provider: 'test', dueDate: null });
    return id;
  }

  it('threshold 미만: 빈 결과', () => {
    insertWithTag('n1', 'mlx-ops');
    insertWithTag('n2', 'mlx-ops');
    expect(repo.findPromotionCandidates(defaultId)).toEqual([]);
  });

  it('threshold 도달: tag 와 noteIds 반환', () => {
    const a = insertWithTag('n1', 'mlx-ops');
    const b = insertWithTag('n2', 'mlx-ops');
    const c = insertWithTag('n3', 'mlx-ops');
    const r = repo.findPromotionCandidates(defaultId);
    expect(r).toHaveLength(1);
    expect(r[0]!.tag).toBe('mlx-ops');
    expect(r[0]!.noteIds.sort()).toEqual([a, b, c].sort());
  });

  it('default 가 아닌 notebook 의 노트는 제외', () => {
    const a = insertWithTag('n1', 'mlx-ops');
    const b = insertWithTag('n2', 'mlx-ops');
    const c = insertWithTag('n3', 'mlx-ops');
    db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','t','t')`).run();
    db.prepare(`UPDATE notes SET notebook_id='nb-x' WHERE id=?`).run(c);
    expect(repo.findPromotionCandidates(defaultId)).toEqual([]);
  });

  it('trashed/completed 제외 — active 만', () => {
    insertWithTag('n1', 'mlx-ops');
    insertWithTag('n2', 'mlx-ops');
    const c = insertWithTag('n3', 'mlx-ops');
    repo.setStatus(c, 'completed', null);
    expect(repo.findPromotionCandidates(defaultId)).toEqual([]);
  });
});
  • Step 2: findPromotionCandidates 메서드 추가

NoteRepository.ts:

/**
 * default notebook 안 active 노트들 중 동일 tag 가 3건 이상 모인 클러스터 검출.
 * PromotionBanner 가 사용 — 각 결과는 {tag, noteIds}.
 */
findPromotionCandidates(
  defaultNotebookId: string,
  threshold: number = 3
): Array<{ tag: string; noteIds: string[] }> {
  const rows = this.db.prepare(
    `SELECT t.name AS tag, GROUP_CONCAT(n.id) AS ids, COUNT(DISTINCT n.id) AS cnt
       FROM tags t
       JOIN note_tags nt ON nt.tag_id = t.id
       JOIN notes n ON n.id = nt.note_id
      WHERE n.status = 'active'
        AND n.notebook_id = ?
      GROUP BY t.id
     HAVING cnt >= ?`
  ).all(defaultNotebookId, threshold) as Array<{ tag: string; ids: string; cnt: number }>;
  return rows.map((r) => ({ tag: r.tag, noteIds: r.ids.split(',') }));
}
  • Step 3: 테스트 통과 확인
npx vitest run tests/unit/NoteRepository.test.ts

Expected: all passed (신규 4건 포함)

  • Step 4: commit
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(notes): findPromotionCandidates — tag 3건 default notebook 클러스터"

Task 6: notebookApi IPC + preload exposure

Files:

  • Create: src/main/ipc/notebookApi.ts

  • Modify: src/main/index.ts, src/shared/types.ts, src/preload/index.ts

  • Test: tests/unit/notebookApi.test.ts

  • Step 1: shared/types.ts 에 NotebookApi 추가

src/shared/types.ts:

export interface NotebookApi {
  list(): Promise<Notebook[]>;
  create(input: { name: string; color?: string }): Promise<{ ok: true; notebook: Notebook } | { ok: false; reason: string }>;
  rename(id: string, name: string): Promise<{ ok: true } | { ok: false; reason: string }>;
  setColor(id: string, color: string | null): Promise<{ ok: true }>;
  delete(id: string): Promise<{ ok: true } | { ok: false; reason: 'has_notes' | 'not_found' }>;
  moveNote(noteId: string, notebookId: string): Promise<{ ok: true }>;
}
  • Step 2: 실패하는 test 작성

tests/unit/notebookApi.test.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';

vi.mock('electron', () => ({
  default: { ipcMain: { handle: vi.fn(), on: vi.fn() } }
}));

import electron from 'electron';
import { registerNotebookApi } from '../../src/main/ipc/notebookApi.js';

function getHandler(channel: string): (...args: unknown[]) => unknown {
  const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
  const call = handle.mock.calls.find((c) => c[0] === channel);
  if (!call) throw new Error(`channel ${channel} not registered`);
  return call[1] as (...args: unknown[]) => unknown;
}

function makeRepo(): { list: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; rename: ReturnType<typeof vi.fn>; setColor: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn>; moveNote: ReturnType<typeof vi.fn>; findById: ReturnType<typeof vi.fn> } {
  return {
    list: vi.fn(() => []),
    create: vi.fn(),
    rename: vi.fn(),
    setColor: vi.fn(),
    delete: vi.fn(() => ({ ok: true })),
    moveNote: vi.fn(),
    findById: vi.fn(() => null)
  };
}

describe('notebookApi', () => {
  beforeEach(() => {
    (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
  });

  it('notebook:list — repo.list 결과 반환', async () => {
    const repo = makeRepo();
    repo.list.mockReturnValue([{ id: '1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }]);
    registerNotebookApi({ repo: repo as never });
    const h = getHandler('notebook:list');
    const r = await h({});
    expect(r).toHaveLength(1);
  });

  it('notebook:create — UNIQUE 위반 시 ok:false', async () => {
    const repo = makeRepo();
    repo.create.mockImplementation(() => { throw new Error('UNIQUE constraint failed: notebooks.name'); });
    registerNotebookApi({ repo: repo as never });
    const h = getHandler('notebook:create');
    const r = await h({}, { name: '회사' });
    expect((r as { ok: boolean }).ok).toBe(false);
  });

  it('notebook:delete — has_notes 시 ok:false reason 그대로 전달', async () => {
    const repo = makeRepo();
    repo.delete.mockReturnValue({ ok: false, reason: 'has_notes' });
    registerNotebookApi({ repo: repo as never });
    const h = getHandler('notebook:delete');
    const r = await h({}, 'id1');
    expect(r).toEqual({ ok: false, reason: 'has_notes' });
  });
});
  • Step 3: notebookApi 구현

src/main/ipc/notebookApi.ts:

import electron from 'electron';
const { ipcMain } = electron;
import type { NotebookRepository } from '../repository/NotebookRepository.js';

export interface NotebookIpcDeps {
  repo: NotebookRepository;
}

export function registerNotebookApi(deps: NotebookIpcDeps): void {
  ipcMain.handle('notebook:list', () => deps.repo.list());

  ipcMain.handle('notebook:create', (_e, input: { name: string; color?: string }) => {
    if (!input.name || input.name.trim().length === 0) {
      return { ok: false as const, reason: 'empty_name' };
    }
    try {
      const nb = deps.repo.create({ name: input.name.trim(), color: input.color ?? null });
      return { ok: true as const, notebook: nb };
    } catch (e) {
      const msg = (e as Error).message;
      if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' };
      return { ok: false as const, reason: msg };
    }
  });

  ipcMain.handle('notebook:rename', (_e, id: string, name: string) => {
    if (!name || name.trim().length === 0) {
      return { ok: false as const, reason: 'empty_name' };
    }
    try {
      deps.repo.rename(id, name.trim());
      return { ok: true as const };
    } catch (e) {
      const msg = (e as Error).message;
      if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' };
      return { ok: false as const, reason: msg };
    }
  });

  ipcMain.handle('notebook:set-color', (_e, id: string, color: string | null) => {
    deps.repo.setColor(id, color);
    return { ok: true as const };
  });

  ipcMain.handle('notebook:delete', (_e, id: string) => deps.repo.delete(id));

  ipcMain.handle('notebook:move-note', (_e, noteId: string, notebookId: string) => {
    deps.repo.moveNote(noteId, notebookId);
    return { ok: true as const };
  });
}
  • Step 4: preload 에 노출

src/preload/index.ts — 기존 inboxApi exposure 옆에 추가:

notebookApi: {
  list: () => ipcRenderer.invoke('notebook:list'),
  create: (input: { name: string; color?: string }) => ipcRenderer.invoke('notebook:create', input),
  rename: (id: string, name: string) => ipcRenderer.invoke('notebook:rename', id, name),
  setColor: (id: string, color: string | null) => ipcRenderer.invoke('notebook:set-color', id, color),
  delete: (id: string) => ipcRenderer.invoke('notebook:delete', id),
  moveNote: (noteId: string, notebookId: string) => ipcRenderer.invoke('notebook:move-note', noteId, notebookId)
}

contextBridge.exposeInMainWorld 호출 안에 포함되도록 위치 잡기.

  • Step 5: main/index.ts 에 registerNotebookApi 호출

src/main/index.ts — repo 초기화 직후:

import { NotebookRepository } from './repository/NotebookRepository.js';
import { registerNotebookApi } from './ipc/notebookApi.js';
// ...
const notebookRepo = new NotebookRepository(db);
registerNotebookApi({ repo: notebookRepo });
  • Step 6: 테스트 통과 확인
npx vitest run tests/unit/notebookApi.test.ts

Expected: 3 passed

  • Step 7: commit
git add src/main/ipc/notebookApi.ts src/main/index.ts src/preload/index.ts src/shared/types.ts tests/unit/notebookApi.test.ts
git commit -m "feat(ipc): notebookApi — list/create/rename/setColor/delete/moveNote"

Task 7: inboxApi 의 list/listByStatus/countsByStatus/search 에 notebookId 옵션

Files:

  • Modify: src/main/ipc/inboxApi.ts, src/shared/types.ts, src/preload/index.ts, src/renderer/inbox/api.ts

  • Test: 기존 통합 테스트 또는 신규 unit

  • Step 1: shared/types.ts 의 InboxApi 인터페이스 갱신

listNotes(opts: { limit: number; cursor?: string; notebookId?: string }): Promise<Note[]>;
listByStatus(status: NoteStatus, opts?: { limit?: number; notebookId?: string }): Promise<Note[]>;
countsByStatus(opts?: { notebookId?: string }): Promise<{ active: number; completed: number; trashed: number }>;
search(query: string, opts?: { limit?: number; status?: NoteStatus; notebookId?: string }): Promise<Note[]>;

(archived 필드 제거)

  • Step 2: inboxApi 핸들러 signature 확장

src/main/ipc/inboxApi.ts:

ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string; notebookId?: string }) =>
  deps.repo.list(opts)
);

ipcMain.handle('inbox:list-by-status', (_e, status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}) => {
  const VALID: readonly NoteStatus[] = ['active', 'completed', 'trashed'];  // archived 제거
  if (!VALID.includes(status)) return [] as Note[];
  return deps.repo.listByStatus(status, opts);
});

ipcMain.handle('inbox:counts-by-status', (_e, opts: { notebookId?: string } = {}) => ({
  active: deps.repo.countByStatus('active', opts),
  completed: deps.repo.countByStatus('completed', opts),
  trashed: deps.repo.countByStatus('trashed', opts)
}));

ipcMain.handle('inbox:search', (_e, query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}) =>
  deps.repo.search(query, opts)
);
  • Step 3: preload 의 invoke 시그니처 그대로 (옵션 객체 패스스루)

src/preload/index.ts — 기존 invoke 들이 인자 그대로 패스이라 변경 없음.

  • Step 4: renderer api facade 갱신

src/renderer/inbox/api.ts:

listNotes: (opts: { limit: number; cursor?: string; notebookId?: string }) =>
  ipcRenderer.invoke('inbox:list', opts),
listByStatus: (status: NoteStatus, opts?: { limit?: number; notebookId?: string }) =>
  ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}),
// 등

(또는 기존 facade 의 이미 사용중 패턴 그대로 패스스루)

  • Step 5: 기존 호출자 회귀 검증
npm run typecheck && npm test

Expected: pass — 옵션 객체 미지정 시 기존 동작 보존.

  • Step 6: commit
git add src/main/ipc/inboxApi.ts src/shared/types.ts src/renderer/inbox/api.ts
git commit -m "feat(ipc): inboxApi list/search/counts 에 notebookId 옵션 + archived 제거"

Task 8: AI prompt 에 notebooks 목록 주입 + schema 의 notebook_match 필드

Files:

  • Modify: src/main/ai/prompt.ts, src/main/ai/schema.ts, src/main/ai/InferenceProvider.ts, src/main/ai/LocalOllamaProvider.ts

  • Test: tests/unit/prompt.test.ts, tests/unit/schema.test.ts

  • Step 1: prompt.ts 실패하는 test 추가

tests/unit/prompt.test.ts:

import { describe, it, expect } from 'vitest';
import { buildPrompt } from '../../src/main/ai/prompt.js';

describe('buildPrompt with notebooks', () => {
  it('notebooks 목록이 prompt 에 포함됨', () => {
    const p = buildPrompt('hi', '2026-05-14', [], [], ['기본', '회사']);
    expect(p).toContain('기본');
    expect(p).toContain('회사');
    expect(p).toContain('notebook_match');
  });

  it('notebooks 빈 배열 시 notebook 섹션 생략', () => {
    const p = buildPrompt('hi', '2026-05-14', [], [], []);
    expect(p).not.toContain('notebook_match');
  });
});
  • Step 2: buildPrompt signature 확장 + notebook 블록

src/main/ai/prompt.ts:

export function buildPrompt(
  rawText: string,
  todayKst: string,
  candidates: ParseResult[] = [],
  vocab: string[] = [],
  notebooks: string[] = []
): string {
  // ... 기존 candidateBlock / vocabBlock ...
  const notebookBlock = notebooks.length > 0
    ? `\n사용 가능한 노트북: ${notebooks.join(', ')}\n이 노트가 위 노트북 중 하나에 명확히 속하면 "notebook_match" 에 그 이름을, 그렇지 않으면 null 을 반환. 기존 목록 안에서만 선택 — 새 이름 만들지 말 것.\n`
    : '';
  // ... return template 에 notebookBlock 추가 + notebook_match 키 명세도 추가:
  // - "notebook_match": "사용 가능한 노트북" 목록 중 하나 또는 null
}
  • Step 3: schema.ts 의 RawResponseSchema 갱신

src/main/ai/schema.ts:

const RawResponseSchema = z.object({
  title: z.string().trim().min(1).max(200),
  summary: z.string().min(1),
  tags: z.array(z.string()).default([]),
  due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional(),
  notebook_match: z.string().nullable().optional()
});

export interface AiResponse {
  title: string;
  summary: string;
  tags: string[];
  dueDate: string | null;
  notebookMatch: string | null;  // 추가
}

parseAiResponse 의 반환에 notebookMatch: parsed.notebook_match ?? null 추가.

  • Step 4: schema.test.ts 회귀 검증

tests/unit/schema.test.ts 의 기존 테스트가 새 필드로 깨지면 mock response 에 notebook_match: null 추가.

  • Step 5: InferenceProvider / LocalOllamaProvider GenerateInput 에 notebooks

src/main/ai/InferenceProvider.ts:

export interface GenerateInput {
  text: string;
  images?: Array<{ base64: string; mime: string }>;
  todayKst: string;
  dueDateCandidates: ParseResult[];
  vocab?: string[];
  notebooks?: string[];  // 추가
}

LocalOllamaProvider.generate:

const prompt = useVision
  ? buildVisionPrompt(input.text, input.todayKst, ...)
  : buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [], input.notebooks ?? []);
  • Step 6: 테스트 통과 확인
npx vitest run tests/unit/prompt.test.ts tests/unit/schema.test.ts

Expected: pass

  • Step 7: commit
git add src/main/ai/prompt.ts src/main/ai/schema.ts src/main/ai/InferenceProvider.ts src/main/ai/LocalOllamaProvider.ts tests/unit/prompt.test.ts tests/unit/schema.test.ts
git commit -m "feat(ai): prompt 에 notebooks 목록 + schema 의 notebook_match 필드"

Task 9: AiWorker — notebook_match 처리 (자동 배치)

Files:

  • Modify: src/main/ai/AiWorker.ts

  • Test: tests/unit/AiWorker.test.ts (기존 또는 신규 describe)

  • Step 1: AiWorker 가 notebookRepo + notebooks list 받도록 옵션 확장

AiWorkerOptionsnotebookRepo?: { list(): Notebook[]; moveNote(noteId,nbId): void; findByName(name): Notebook | null } 같은 deps. 단순화: notebookRepo?: Pick<NotebookRepository, 'list' | 'moveNote'> + findByName 헬퍼 추가.

NotebookRepository 에 findByName(name) 메서드 추가:

findByName(name: string): Notebook | null {
  const r = this.db.prepare(
    `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
            (SELECT COUNT(*) FROM notes n WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
       FROM notebooks nb WHERE nb.name = ?`
  ).get(name) as Record<string, unknown> | undefined;
  return r ? this.hydrate(r) : null;
}
  • Step 2: AiWorker.processJob 에 notebook matching 통합

src/main/ai/AiWorker.ts — generate 호출 직전:

const notebooks = this.notebookRepo ? this.notebookRepo.list().map((nb) => nb.name) : [];
// ...
const res = await this.holder.get().generate(
  { text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab, notebooks },
  { visionModel: visionModel ?? undefined }
);

성공 후 (updateAiResult 직후) notebook 매칭:

// res.notebookMatch 가 valid notebook 이름이면 자동 이동.
if (res.notebookMatch && this.notebookRepo) {
  const nb = this.notebookRepo.findByName(res.notebookMatch);
  if (nb) {
    this.notebookRepo.moveNote(job.noteId, nb.id);
    this.logger.info('ai.notebook.match', { noteId: job.noteId, notebook: nb.name });
  } else {
    this.logger.info('ai.notebook.miss', { noteId: job.noteId, attempted: res.notebookMatch });
  }
}
  • Step 3: 실패하는 test 추가

tests/unit/AiWorker.test.ts:

it('AI 응답의 notebook_match 가 valid 이름이면 moveNote 호출', async () => {
  const moveNote = vi.fn();
  const notebookRepo = {
    list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
    moveNote,
    findByName: (n: string) => n === '회사' ? { id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } : null
  };
  // ... worker setup with notebookRepo, mock provider returning notebookMatch:'회사' ...
  // capture + run worker
  expect(moveNote).toHaveBeenCalledWith(expect.any(String), 'nb-회사');
});

it('notebook_match null 이면 moveNote 호출 안 함 (default 유지)', async () => {
  // ... 비슷한 setup, provider 가 notebookMatch:null 반환 ...
  expect(moveNote).not.toHaveBeenCalled();
});

(기존 AiWorker.test 패턴 따라 fake provider / repo 사용)

  • Step 4: 테스트 통과 확인
npx vitest run tests/unit/AiWorker.test.ts

Expected: pass

  • Step 5: commit
git add src/main/ai/AiWorker.ts src/main/repository/NotebookRepository.ts tests/unit/AiWorker.test.ts tests/unit/NotebookRepository.test.ts
git commit -m "feat(ai): AiWorker — notebook_match 매치 시 자동 moveNote"

Task 10: store — notebooks state + actions

Files:

  • Modify: src/renderer/inbox/store.ts

  • Test: tests/unit/store.notebook.test.ts

  • Step 1: 실패하는 test 작성

tests/unit/store.notebook.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../../src/renderer/inbox/api.js', () => ({
  inboxApi: { /* 기존 mock 패턴 */ },
  notebookApi: {
    list: vi.fn(async () => [
      { id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 },
      { id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 1 }
    ]),
    create: vi.fn(async (i) => ({ ok: true, notebook: { id: 'nb-new', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 } })),
    delete: vi.fn(async () => ({ ok: true })),
    rename: vi.fn(async () => ({ ok: true })),
    moveNote: vi.fn(async () => ({ ok: true }))
  }
}));

import { useInbox } from '../../src/renderer/inbox/store.js';

describe('store notebooks', () => {
  beforeEach(() => {
    useInbox.setState({
      notebooks: [],
      selectedNotebookId: null,
      sidebarVisible: false
    } as never);
  });

  it('loadNotebooks 가 notebookApi.list 결과 반영', async () => {
    await useInbox.getState().loadNotebooks();
    expect(useInbox.getState().notebooks).toHaveLength(2);
  });

  it('selectNotebook 가 selectedNotebookId 설정', () => {
    useInbox.getState().selectNotebook('nb-1');
    expect(useInbox.getState().selectedNotebookId).toBe('nb-1');
  });

  it('createNotebook 성공 시 notebooks 에 추가', async () => {
    await useInbox.getState().createNotebook('학습', '#ccc');
    expect(useInbox.getState().notebooks.some((n) => n.name === '학습')).toBe(true);
  });

  it('toggleSidebar 가 sidebarVisible 반전', () => {
    expect(useInbox.getState().sidebarVisible).toBe(false);
    useInbox.getState().toggleSidebar();
    expect(useInbox.getState().sidebarVisible).toBe(true);
  });
});
  • Step 2: store.ts 에 notebooks state 추가

src/renderer/inbox/store.ts:

interface InboxState {
  // ... 기존 ...
  notebooks: Notebook[];
  selectedNotebookId: string | null;
  sidebarVisible: boolean;
  sidebarWidth: number;
  loadNotebooks: () => Promise<void>;
  selectNotebook: (id: string) => void;
  createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>;
  renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>;
  deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
  moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
  toggleSidebar: () => void;
}

구현 (zustand store body 에서):

notebooks: [],
selectedNotebookId: null,
sidebarVisible: false,
sidebarWidth: 240,

async loadNotebooks() {
  const list = await notebookApi.list();
  set({ notebooks: list, selectedNotebookId: get().selectedNotebookId ?? list[0]?.id ?? null });
},

selectNotebook(id) { set({ selectedNotebookId: id }); },

async createNotebook(name, color) {
  const r = await notebookApi.create({ name, color });
  if (r.ok) {
    set({ notebooks: [...get().notebooks, r.notebook] });
    return { ok: true };
  }
  return { ok: false, reason: r.reason };
},

async renameNotebook(id, name) {
  const r = await notebookApi.rename(id, name);
  if (r.ok) {
    set({ notebooks: get().notebooks.map((nb) => nb.id === id ? { ...nb, name } : nb) });
    return { ok: true };
  }
  return { ok: false, reason: r.reason };
},

async deleteNotebook(id) {
  const r = await notebookApi.delete(id);
  if (r.ok) {
    set({ notebooks: get().notebooks.filter((nb) => nb.id !== id) });
    if (get().selectedNotebookId === id) set({ selectedNotebookId: get().notebooks[0]?.id ?? null });
    return { ok: true };
  }
  return { ok: false, reason: r.reason };
},

async moveNoteToNotebook(noteId, notebookId) {
  await notebookApi.moveNote(noteId, notebookId);
  await get().refreshMeta();
},

toggleSidebar() { set({ sidebarVisible: !get().sidebarVisible }); },
  • Step 3: 테스트 통과 확인
npx vitest run tests/unit/store.notebook.test.ts

Expected: 4 passed

  • Step 4: commit
git add src/renderer/inbox/store.ts tests/unit/store.notebook.test.ts
git commit -m "feat(store): notebooks state + actions (load/select/create/rename/delete/move/toggle)"

Task 11: store — promotionCandidate state + action

Files:

  • Modify: src/renderer/inbox/store.ts, src/shared/types.ts, src/preload/index.ts, src/main/ipc/inboxApi.ts

  • Test: tests/unit/store.notebook.test.ts (확장)

  • Step 1: inboxApi 에 list-promotion-candidates IPC 추가

src/main/ipc/inboxApi.ts:

ipcMain.handle('inbox:list-promotion-candidates', () => {
  const defaultNb = deps.notebookRepo.list()[0];
  if (!defaultNb) return [];
  return deps.repo.findPromotionCandidates(defaultNb.id);
});

deps 에 notebookRepo 추가. preload + renderer api facade 도 동일.

  • Step 2: PromotionCandidate type 추가

src/shared/types.ts:

export interface PromotionCandidate {
  tag: string;
  noteIds: string[];
  suggestedName: string;  // tag 이름 Title Case
}
  • Step 3: store 에 promotionCandidate state

src/renderer/inbox/store.ts:

promotionCandidates: PromotionCandidate[];
async loadPromotionCandidates() {
  const dismissed = await inboxApi.getPromotionDismissedTags();
  const snoozedUntil = await inboxApi.getPromotionSnoozeUntil();
  if (snoozedUntil > Date.now()) { set({ promotionCandidates: [] }); return; }
  const raw = await inboxApi.listPromotionCandidates();
  const filtered = raw.filter((c) => !dismissed.includes(c.tag));
  set({ promotionCandidates: filtered.map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) })) });
},
async acceptPromotion(tag, customName, color) {
  const c = get().promotionCandidates.find((p) => p.tag === tag);
  if (!c) return;
  const created = await notebookApi.create({ name: customName, color });
  if (!created.ok) return;
  await Promise.all(c.noteIds.map((id) => notebookApi.moveNote(id, created.notebook.id)));
  set({
    notebooks: [...get().notebooks, created.notebook],
    promotionCandidates: get().promotionCandidates.filter((p) => p.tag !== tag),
    sidebarVisible: true,
    selectedNotebookId: created.notebook.id
  });
  await get().refreshMeta();
},
async snoozePromotion() {
  const until = Date.now() + 24 * 60 * 60 * 1000;
  await inboxApi.setPromotionSnoozeUntil(until);
  set({ promotionCandidates: [] });
},
async dismissPromotion(tag) {
  await inboxApi.addPromotionDismissedTag(tag);
  set({ promotionCandidates: get().promotionCandidates.filter((p) => p.tag !== tag) });
}

toTitleCase 헬퍼:

function toTitleCase(s: string): string {
  return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
  • Step 4: SettingsService 에 promotion 상태 저장

src/main/services/SettingsService.ts schema 확장 (Zod):

promotion_dismissed_tags: z.array(z.string()).optional(),
promotion_snoozed_until_ms: z.number().int().optional(),
sidebar_visible: z.boolean().optional(),
sidebar_width: z.number().int().min(180).max(400).optional()

각각 getter / setter 메서드 추가. settingsApi IPC 핸들러도 노출 (inbox:get-promotion-dismissed-tags 등).

  • Step 5: 테스트 추가

tests/unit/store.notebook.test.ts 확장:

it('acceptPromotion 가 새 notebook 생성 + 노트 일괄 이동 + 사이드바 자동 열기', async () => {
  // mock setup ...
  useInbox.setState({ promotionCandidates: [{ tag: 'mlx-ops', noteIds: ['n1','n2'], suggestedName: 'Mlx Ops' }] } as never);
  await useInbox.getState().acceptPromotion('mlx-ops', 'MLX Ops', null);
  expect(useInbox.getState().sidebarVisible).toBe(true);
  expect(useInbox.getState().promotionCandidates).toHaveLength(0);
});
  • Step 6: 테스트 통과 확인 + commit
npx vitest run tests/unit/store.notebook.test.ts
git add src/renderer/inbox/store.ts src/shared/types.ts src/main/ipc/inboxApi.ts src/main/services/SettingsService.ts src/preload/index.ts src/renderer/inbox/api.ts tests/unit/store.notebook.test.ts
git commit -m "feat(promotion): store promotionCandidates + accept/snooze/dismiss + settings 저장"

Task 12: Sidebar 컴포넌트 + NotebookList

Files:

  • Create: src/renderer/inbox/components/Sidebar.tsx, src/renderer/inbox/components/NotebookList.tsx

  • Test: tests/unit/Sidebar.test.tsx, tests/unit/NotebookList.test.tsx

  • Step 1: NotebookList 실패 test

tests/unit/NotebookList.test.tsx:

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { NotebookList } from '../../src/renderer/inbox/components/NotebookList';

const notebooks = [
  { id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 },
  { id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 7 }
];

describe('NotebookList', () => {
  beforeEach(cleanup);

  it('노트북 이름 + count 렌더링', () => {
    render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} />);
    expect(screen.getByText('기본')).toBeInTheDocument();
    expect(screen.getByText('3')).toBeInTheDocument();
    expect(screen.getByText('회사')).toBeInTheDocument();
    expect(screen.getByText('7')).toBeInTheDocument();
  });

  it('클릭 시 onSelect 호출', () => {
    const onSelect = vi.fn();
    render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={onSelect} onCreate={() => {}} />);
    fireEvent.click(screen.getByText('회사'));
    expect(onSelect).toHaveBeenCalledWith('nb-2');
  });

  it('+ 새 노트북 클릭 시 onCreate 호출', () => {
    const onCreate = vi.fn();
    render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={onCreate} />);
    fireEvent.click(screen.getByRole('button', { name: /새 노트북/ }));
    expect(onCreate).toHaveBeenCalled();
  });
});
  • Step 2: NotebookList 구현

src/renderer/inbox/components/NotebookList.tsx:

import React from 'react';
import type { Notebook } from '@shared/types';

interface Props {
  notebooks: Notebook[];
  selectedId: string | null;
  onSelect: (id: string) => void;
  onCreate: () => void;
}

export function NotebookList({ notebooks, selectedId, onSelect, onCreate }: Props): React.ReactElement {
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {notebooks.map((nb) => {
        const active = nb.id === selectedId;
        return (
          <button
            key={nb.id}
            onClick={() => onSelect(nb.id)}
            style={{
              display: 'flex', alignItems: 'center', gap: 8,
              padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
              border: 'none', cursor: 'pointer', textAlign: 'left',
              color: active ? '#0a4b80' : '#333', fontSize: 13
            }}
          >
            <span style={{
              width: 8, height: 8, borderRadius: '50%',
              background: nb.color ?? '#bbb', flexShrink: 0
            }} />
            <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {nb.name}
            </span>
            <span style={{ fontSize: 11, color: '#888' }}>{nb.noteCount}</span>
          </button>
        );
      })}
      <button
        onClick={onCreate}
        style={{
          padding: '6px 12px', background: 'transparent', border: 'none',
          cursor: 'pointer', textAlign: 'left', color: '#888', fontSize: 12
        }}
      >
        +  노트북
      </button>
    </div>
  );
}
  • Step 3: Sidebar 실패 test

tests/unit/Sidebar.test.tsx:

// @vitest-environment jsdom
// store mock + render Sidebar
// 검증: sidebarVisible=false 면 null, true 면 NotebookList 렌더링
  • Step 4: Sidebar 구현

src/renderer/inbox/components/Sidebar.tsx:

import React, { useState } from 'react';
import { useInbox } from '../store.js';
import { NotebookList } from './NotebookList.js';
import { NotebookCreateModal } from './NotebookCreateModal.js';

export function Sidebar(): React.ReactElement | null {
  const visible = useInbox((s) => s.sidebarVisible);
  const width = useInbox((s) => s.sidebarWidth);
  const notebooks = useInbox((s) => s.notebooks);
  const selectedId = useInbox((s) => s.selectedNotebookId);
  const selectNotebook = useInbox((s) => s.selectNotebook);
  const [createOpen, setCreateOpen] = useState(false);
  if (!visible) return null;
  return (
    <aside style={{
      width, borderRight: '1px solid #e0e0e0',
      background: '#fafafa', overflowY: 'auto',
      flexShrink: 0
    }}>
      <NotebookList
        notebooks={notebooks}
        selectedId={selectedId}
        onSelect={selectNotebook}
        onCreate={() => setCreateOpen(true)}
      />
      {createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
    </aside>
  );
}
  • Step 5: 테스트 통과 + commit
npx vitest run tests/unit/NotebookList.test.tsx tests/unit/Sidebar.test.tsx
git add src/renderer/inbox/components/Sidebar.tsx src/renderer/inbox/components/NotebookList.tsx tests/unit/NotebookList.test.tsx tests/unit/Sidebar.test.tsx
git commit -m "feat(ui): Sidebar + NotebookList — notebook 목록 + count + 선택"

Task 13: NotebookCreateModal

Files:

  • Create: src/renderer/inbox/components/NotebookCreateModal.tsx

  • Test: tests/unit/NotebookCreateModal.test.tsx

  • Step 1: 실패 test

// 이름 입력 + 색 선택 + 확인 → store.createNotebook 호출 + 모달 닫힘
// 빈 이름 시 확인 disabled
// 중복 이름 시 reason 표시
  • Step 2: 구현

NotebookCreateModal.tsx:

import React, { useState } from 'react';
import { useInbox } from '../store.js';

const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c', '#1a5b6e'];

export function NotebookCreateModal({ onClose }: { onClose: () => void }): React.ReactElement {
  const createNotebook = useInbox((s) => s.createNotebook);
  const [name, setName] = useState('');
  const [color, setColor] = useState<string>(COLOR_PALETTE[0]!);
  const [err, setErr] = useState<string | null>(null);

  async function onSubmit() {
    const r = await createNotebook(name.trim(), color);
    if (r.ok) onClose();
    else setErr(r.reason === 'duplicate_name' ? '같은 이름의 노트북이 이미 있어요.' : (r.reason ?? '저장 실패'));
  }

  return (
    <div
      onClick={onClose}
      style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 120 }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{ background: '#fff', padding: 20, borderRadius: 8, width: 360 }}
      >
        <h3 style={{ margin: '0 0 12px 0', fontSize: 15 }}> 노트북</h3>
        <input
          aria-label="노트북 이름"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="예: 회사, 학습"
          autoFocus
          style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
        />
        <div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
          {COLOR_PALETTE.map((c) => (
            <button
              key={c}
              onClick={() => setColor(c)}
              aria-label={`색 ${c}`}
              style={{
                width: 24, height: 24, borderRadius: '50%',
                background: c, border: c === color ? '2px solid #333' : '1px solid #ccc',
                cursor: 'pointer'
              }}
            />
          ))}
        </div>
        {err && <div style={{ color: '#c33', fontSize: 12, marginTop: 8 }}>{err}</div>}
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14 }}>
          <button onClick={onClose} style={{ padding: '5px 12px', fontSize: 12 }}>취소</button>
          <button
            onClick={() => { void onSubmit(); }}
            disabled={name.trim().length === 0}
            style={{ padding: '5px 12px', fontSize: 12, background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4 }}
          >
            만들기
          </button>
        </div>
      </div>
    </div>
  );
}
  • Step 3: 테스트 통과 + commit
npx vitest run tests/unit/NotebookCreateModal.test.tsx
git add src/renderer/inbox/components/NotebookCreateModal.tsx tests/unit/NotebookCreateModal.test.tsx
git commit -m "feat(ui): NotebookCreateModal — 이름 + 색 + 중복 검증"

Task 14: PromotionBanner

Files:

  • Create: src/renderer/inbox/components/PromotionBanner.tsx

  • Test: tests/unit/PromotionBanner.test.tsx

  • Step 1: 실패 test

tests/unit/PromotionBanner.test.tsx:

it('promotionCandidates 비어있으면 null', () => {});
it('첫 candidate 의 tag/suggestedName 표시', () => {});
it('수락 클릭 → modal 띄움 → 이름 입력 → store.acceptPromotion 호출', () => {});
it('나중에 클릭 → store.snoozePromotion 호출', () => {});
it('숨기기 클릭 → store.dismissPromotion 호출 (tag)', () => {});
  • Step 2: 구현

PromotionBanner.tsx:

import React, { useState } from 'react';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';

const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c'];

export function PromotionBanner(): React.ReactElement | null {
  const candidates = useInbox((s) => s.promotionCandidates);
  const accept = useInbox((s) => s.acceptPromotion);
  const snooze = useInbox((s) => s.snoozePromotion);
  const dismiss = useInbox((s) => s.dismissPromotion);
  const [editing, setEditing] = useState<{ tag: string; name: string; color: string } | null>(null);

  if (candidates.length === 0) return null;
  const c = candidates[0]!;

  return (
    <Banner severity="info">
      {editing === null ? (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span>💡 <code>{c.tag}</code> 관련 노트 {c.noteIds.length}개가 모였어요.  노트북 <b>{c.suggestedName}</b>  분리할까요?</span>
          <div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
            <button
              onClick={() => setEditing({ tag: c.tag, name: c.suggestedName, color: COLOR_PALETTE[0]! })}
              style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
            >
              수락
            </button>
            <button
              onClick={() => snooze()}
              style={{ background: 'transparent', color: '#234', border: '1px solid #ccc', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
            >
              나중에
            </button>
            <button
              onClick={() => dismiss(c.tag)}
              style={{ background: 'transparent', color: '#888', border: 'none', cursor: 'pointer', fontSize: 12 }}
            >
              숨기기
            </button>
          </div>
        </div>
      ) : (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <input
            value={editing.name}
            onChange={(e) => setEditing({ ...editing, name: e.target.value })}
            style={{ flex: 1, fontSize: 13, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4, minWidth: 120 }}
            autoFocus
          />
          <div style={{ display: 'flex', gap: 4 }}>
            {COLOR_PALETTE.map((col) => (
              <button
                key={col}
                onClick={() => setEditing({ ...editing, color: col })}
                aria-label={`색 ${col}`}
                style={{ width: 20, height: 20, borderRadius: '50%', background: col, border: col === editing.color ? '2px solid #333' : '1px solid #ccc', cursor: 'pointer' }}
              />
            ))}
          </div>
          <button
            onClick={() => { void accept(editing.tag, editing.name.trim(), editing.color); setEditing(null); }}
            disabled={editing.name.trim().length === 0}
            style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
          >
            만들기
          </button>
          <button onClick={() => setEditing(null)} style={{ fontSize: 12 }}>취소</button>
        </div>
      )}
    </Banner>
  );
}
  • Step 3: 테스트 통과 + commit
npx vitest run tests/unit/PromotionBanner.test.tsx
git add src/renderer/inbox/components/PromotionBanner.tsx tests/unit/PromotionBanner.test.tsx
git commit -m "feat(promotion): PromotionBanner — 수락/나중에/숨기기 + inline 이름·색 수정"

Task 15: App.tsx 통합 — Sidebar 레이아웃, 헤더 3탭, Cmd+B 단축키, Banner 위치

Files:

  • Modify: src/renderer/inbox/App.tsx

  • Test: tests/unit/App.test.tsx

  • Step 1: 헤더 탭 archived 제거

App.tsx 의 헤더 탭 영역에서 "보관(N)" 버튼 제거. counts 객체에서도 archived 키 제거.

  • Step 2: 본문을 flex 컨테이너로 + Sidebar 좌측 배치

App.tsx return 구조 변경:

<div style={{ display: 'flex', height: '100vh' }}>
  <Sidebar />
  <main style={{ flex: 1, overflowY: 'auto' }}>
    {/* 기존 헤더 + Banner 들 + NoteCard list */}
  </main>
</div>

<Sidebar /> import + <PromotionBanner /> 를 ExpiryBanner / RecallBanner 옆에 배치.

  • Step 3: Cmd+B / Ctrl+B 단축키

App 최상단 useEffect 에:

useEffect(() => {
  const isMac = /Mac/i.test(navigator.platform);
  const handler = (e: KeyboardEvent) => {
    if (e.key === 'b' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) {
      e.preventDefault();
      useInbox.getState().toggleSidebar();
    }
  };
  window.addEventListener('keydown', handler);
  return () => window.removeEventListener('keydown', handler);
}, []);
  • Step 4: app 초기화에서 loadNotebooks + loadPromotionCandidates 호출

loadInitial 또는 useEffect mount 단계:

await useInbox.getState().loadNotebooks();
await useInbox.getState().loadPromotionCandidates();
  • Step 5: 헤더 탭 클릭 시 selectedNotebookId 기준으로 count 가져오기

기존 counts 가 server-authoritative 라면 inboxApi.countsByStatus({ notebookId: selectedId }) 로 fetch. 또는 store 에서 자동 갱신.

  • Step 6: 테스트 + commit
npm run typecheck && npm test
git add src/renderer/inbox/App.tsx tests/unit/App.test.tsx
git commit -m "feat(app): Sidebar 통합 + 헤더 3탭 + Cmd+B 단축키 + PromotionBanner"

Task 16: MoveStatusModal — archived 옵션 제거

Files:

  • Modify: src/renderer/inbox/components/MoveStatusModal.tsx

  • Test: tests/unit/MoveStatusModal.test.tsx (기존)

  • Step 1: 기존 test 의 "보관" 케이스 제거 또는 archived → no-op 검증

  • Step 2: MoveStatusModal 의 status 선택지에서 archived 옵션 제거

archived 버튼 / option 삭제. AI classifyStatus 호출 결과가 archived 면 client side 에서 completed 로 coerce.

  • Step 3: 테스트 통과 + commit
npx vitest run tests/unit/MoveStatusModal.test.tsx
git add src/renderer/inbox/components/MoveStatusModal.tsx tests/unit/MoveStatusModal.test.tsx
git commit -m "fix(move): archived 옵션 제거 — lifecycle 3분기"

Task 17: NoteCard — notebook chip + 1-click 이동

Files:

  • Modify: src/renderer/inbox/components/NoteCard.tsx

  • Test: tests/unit/NoteCard.test.tsx

  • Step 1: 실패 test 추가

it('NoteCard 가 현재 notebook 이름 chip 렌더링', () => {
  // mock store 의 notebooks → render → chip 텍스트 확인
});

it('chip 클릭 → 다른 notebook 선택 dropdown → store.moveNoteToNotebook 호출', () => {});
  • Step 2: NoteCard 안 notebook chip 추가

NoteCard.tsx — notebook 이름 표시 + 클릭 시 dropdown:

const notebooks = useInbox((s) => s.notebooks);
const move = useInbox((s) => s.moveNoteToNotebook);
const currentNb = notebooks.find((nb) => nb.id === local.notebookId);

// NoteCard 의 적절한 위치 (title 옆 또는 footer) 에:
{currentNb && (
  <NotebookChip
    current={currentNb}
    notebooks={notebooks}
    onMove={async (newId) => {
      await move(local.id, newId);
      setLocal({ ...local, notebookId: newId });
    }}
  />
)}

별도 NotebookChip 컴포넌트 신설 또는 inline. inline 으로:

function NotebookChip({ current, notebooks, onMove }: {
  current: Notebook; notebooks: Notebook[]; onMove: (id: string) => Promise<void>
}) {
  const [open, setOpen] = useState(false);
  return (
    <span style={{ position: 'relative', display: 'inline-block' }}>
      <button
        onClick={() => setOpen(!open)}
        style={{
          display: 'inline-flex', alignItems: 'center', gap: 4,
          background: '#f0f0f0', border: 'none', borderRadius: 10,
          padding: '2px 8px', fontSize: 11, cursor: 'pointer'
        }}
      >
        <span style={{ width: 6, height: 6, borderRadius: '50%', background: current.color ?? '#bbb' }} />
        {current.name}
      </button>
      {open && (
        <div style={{ position: 'absolute', top: '100%', left: 0, background: '#fff', border: '1px solid #ccc', borderRadius: 4, zIndex: 50 }}>
          {notebooks.filter((nb) => nb.id !== current.id).map((nb) => (
            <button
              key={nb.id}
              onClick={async () => { await onMove(nb.id); setOpen(false); }}
              style={{ display: 'block', width: '100%', textAlign: 'left', padding: '4px 10px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11 }}
            >
              {nb.name}
            </button>
          ))}
        </div>
      )}
    </span>
  );
}
  • Step 3: 테스트 통과 + commit
npx vitest run tests/unit/NoteCard.test.tsx
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(ui): NoteCard 의 notebook chip + 1-click 이동"

Task 18: search scope — current vs all notebooks

Files:

  • Modify: src/renderer/inbox/components/SearchBox.tsx, src/renderer/inbox/store.ts

  • Test: tests/unit/SearchBox.test.tsx

  • Step 1: SearchBox 옆에 scope toggle 추가

SearchBox.tsx:

const [scope, setScope] = useState<'current' | 'all'>('current');
// fetch 시 notebookId 옵션:
const notebookId = scope === 'current' ? selectedNotebookId : undefined;
const results = await inboxApi.search(query, { notebookId });

UI 에 작은 dropdown 또는 토글:

<select value={scope} onChange={(e) => setScope(e.target.value as 'current' | 'all')}>
  <option value="current"> 노트북</option>
  <option value="all">모든 노트북</option>
</select>
  • Step 2: 테스트 통과 + commit
npx vitest run tests/unit/SearchBox.test.tsx
git add src/renderer/inbox/components/SearchBox.tsx tests/unit/SearchBox.test.tsx
git commit -m "feat(search): scope 토글 — 이 노트북 / 모든 노트북"

Task 19: 헤더 / list 의 selectedNotebookId 필터링 + counts notebookId 반영

Files:

  • Modify: src/renderer/inbox/store.ts, src/renderer/inbox/App.tsx

  • Step 1: store 의 loadInitial / refreshMeta / loadByView 에 selectedNotebookId 반영

async loadInitial() {
  await get().loadNotebooks();
  const nbId = get().selectedNotebookId;
  // 기존 fetch 들 모두 { notebookId: nbId } 전달
}

async refreshMeta() {
  const nbId = get().selectedNotebookId;
  const counts = await inboxApi.countsByStatus({ notebookId: nbId });
  // ...
}

selectedNotebookId 변경 시 자동 refresh:

selectNotebook(id) {
  set({ selectedNotebookId: id });
  void get().loadByView(get().view);
  void get().refreshMeta();
}
  • Step 2: typecheck + 전체 test
npm run typecheck && npm test
  • Step 3: commit
git add src/renderer/inbox/store.ts src/renderer/inbox/App.tsx
git commit -m "feat(store): selectedNotebookId 변경 시 list/counts 자동 refresh"

Task 20: CHANGELOG + dogfood 안내

Files:

  • Modify: CHANGELOG.md, package.json

  • Step 1: package.json version 0.3.14 → 0.4.0

  • Step 2: CHANGELOG.md 상단에 v0.4.0 entry

## [0.4.0] — 2026-XX-XX

Notebooks + Lifecycle Simplification. 19일 dogfood 데이터 (archived 0건 / mlx-ops tag 가 사실상 컨텍스트 그룹 역할) 가 묶음 변경의 근거.

### 추가
- **Notebook 카테고리** — 좌측 사이드바 (Cmd+B / Ctrl+B 토글), 색 + count badge.
- **AI 자동 fit 매칭** — 매 capture 시 AI 가 기존 notebook 중 best-fit 자동 배치. NoteCard 의 chip 으로 1-click 변경.
- **Promotion 제안** — tag 가 3건 이상 default notebook 에 누적되면 "새 notebook 으로 분리?" banner 표시. 24h snooze + 영구 dismiss 지원.
- m008 마이그레이션 — default "기본" notebook 자동 생성 + 모든 기존 노트 배치.

### 변경
- **lifecycle 3분기** — `archived` 제거. 기존 보관 노트 (dogfood 0건) 는 마이그레이션 시 completed 로 합침.
- 헤더 탭: Inbox / 완료 / 휴지통 (3탭).
- 검색 scope 토글 — "이 노트북" / "모든 노트북".

### 게이트
- 단위 테스트 N PASS (notebook CRUD + migration + UI + AI fit/promotion 분기 + status 3분기 회귀)
- typecheck 0 errors
  • Step 3: commit
git add CHANGELOG.md package.json
git commit -m "chore(release): v0.4.0 — notebooks + lifecycle 3분기"

Self-Review

  • Spec coverage: §3 범위 (notebook 모델 B / lifecycle 3분기 / default notebook / 사이드바 토글 / search scope / RESTRICT / 자동 fit) 모두 task 1-19 에 분배.
  • AI × Notebook (§11): Task 8 (prompt + schema) / Task 9 (AiWorker matching) / Task 11 (promotionCandidate store) / Task 14 (PromotionBanner UI) 로 분해.
  • placeholders: TBD / TODO / "implement later" 없음. 모든 step 에 실제 code block.
  • type consistency: Notebook / NotebookApi / PromotionCandidate 모두 shared/types.ts 에 정의, 후속 task 가 동일 시그니처 사용.
  • migration version: m008 (m007 이 FTS 로 사용중이므로 충돌 회피).
  • archived 제거: status enum + UI 헤더 + MoveStatusModal + listByStatus IPC + countsByStatus IPC 일관 적용.
  • sync (Cut E) 통합: spec §10 명시. 본 plan 에서 deferred — frontmatter notebook 필드는 v0.4 본체 머지 후 별도 작업.

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md. Two execution options:

1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration

2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?