diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md
index ca357e2..d8d2f13 100644
--- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md
+++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md
@@ -103,7 +103,9 @@ H1 이 미달이면 본 항목 ❌ rejected.
---
-## F2. 태그 클릭 = 즉시 삭제 + undo 부재 (🌱 raw)
+## F2. 태그 클릭 = 즉시 삭제 + undo 부재 (🚀 promoted)
+
+**진행 상태:** 🚀 promoted → `docs/superpowers/specs/2026-04-26-f2-tag-click.md`
**발견:** 2026-04-25 dogfood 중. 슬라이스 v0.4 동작.
diff --git a/docs/superpowers/specs/2026-04-26-f2-tag-click.md b/docs/superpowers/specs/2026-04-26-f2-tag-click.md
new file mode 100644
index 0000000..9c395c8
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-26-f2-tag-click.md
@@ -0,0 +1,31 @@
+# F2 태그 클릭 Spec (Promoted)
+
+**Extracted from:** `2026-04-25-dogfood-feedback.md` F2
+**Plan:** (inline in this PR description)
+**Status:** 🚀 promoted — implemented 2026-04-26
+
+## 결정 (mini-brainstorm 결과)
+
+| 결정 | 값 |
+|------|-----|
+| 짧은 클릭 | 필터 (Inbox 가 동일 태그만 표시) |
+| 칩의 × | 즉시 태그 제거 + 5초 undo 토스트 |
+| AI/user 태그 | 동일 동작 (시각만 다름) |
+| undo 정책 | 5초 자동 사라짐, 토스트 클릭 시 즉시 복원 |
+| Continuity / Pending 카운트 | 전체 기준 (필터 무시) |
+
+## 범위 (PR 안에 포함됨)
+
+- `src/renderer/inbox/store.ts` 수정 (`tagFilter` + `setTagFilter`, `selectFilteredNotes` re-export)
+- `src/renderer/inbox/selectFilteredNotes.ts` 신규 — 순수 셀렉터 (`api.ts` 의 `window.inkling` 부수효과 회피로 vitest `node` 환경 호환)
+- `src/renderer/inbox/App.tsx` 수정 (필터 배너 + 해제 버튼 + `` 마운트)
+- `src/renderer/inbox/components/NoteCard.tsx` 수정 (칩 분리: 클릭 = 필터 / × = 제거 + undo)
+- `src/renderer/inbox/components/TagUndoToast.tsx` (신규 — 모듈 단위 pub/sub 토스트)
+- `tests/unit/store.tagFilter.test.ts` (신규 — 4 케이스)
+
+## 후속
+
+- 다중 태그 필터 (AND/OR)
+- 태그 rename / merge / 자동완성
+- 태그 단위 일괄 작업
+- undo 시 원래 source(`ai`/`user`) 보존 (현재는 `updateAiFields(tags: string[])` API 가 source 정보 미수용 — backend 가 user 로 처리)
diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx
index deed729..92fce0f 100644
--- a/src/renderer/inbox/App.tsx
+++ b/src/renderer/inbox/App.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
-import { useInbox } from './store.js';
+import { useInbox, selectFilteredNotes } from './store.js';
import { inboxApi } from './api.js';
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
import { NoteCard } from './components/NoteCard.js';
@@ -7,9 +7,20 @@ import { ContinuityBadge } from './components/ContinuityBadge.js';
import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
+import { TagUndoToast } from './components/TagUndoToast.js';
export function App(): React.ReactElement {
- const { notes, loading, loadInitial, refreshMeta, upsertNote, removeNote, continuity } = useInbox();
+ const {
+ notes,
+ loading,
+ loadInitial,
+ refreshMeta,
+ upsertNote,
+ removeNote,
+ continuity,
+ tagFilter,
+ setTagFilter
+ } = useInbox();
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
useEffect(() => {
@@ -24,6 +35,7 @@ export function App(): React.ReactElement {
}, [loadInitial, refreshMeta, upsertNote]);
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
+ const filtered = selectFilteredNotes({ notes, tagFilter });
return (
<>
@@ -38,16 +50,51 @@ export function App(): React.ReactElement {
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
+ {tagFilter !== null && (
+
+ 🔎 필터: #{tagFilter}
+ ({filtered.length}개)
+
+
+ )}
{loading && notes.length === 0 ? (
불러오는 중…
) : notes.length === 0 ? (
첫 기억을 구출해보세요. Ctrl+Shift+J
+ ) : filtered.length === 0 ? (
+ 이 태그의 노트가 없습니다.
) : (
- notes.map((n) => (
+ filtered.map((n) => (
removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
))
)}
+
>
);
}
diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx
index c17c8f6..f7ca235 100644
--- a/src/renderer/inbox/components/NoteCard.tsx
+++ b/src/renderer/inbox/components/NoteCard.tsx
@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import type { Note } from '@shared/types';
import { inboxApi } from '../api.js';
+import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
+import { pushTagUndo } from './TagUndoToast.js';
interface Props {
note: Note;
@@ -140,10 +142,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
}
async function removeTag(tagName: string) {
- const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
- await inboxApi.updateAiFields(note.id, { tags: next });
+ const removed = local.tags.find((t) => t.name === tagName);
+ const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
+ await inboxApi.updateAiFields(note.id, { tags: nextTagNames });
const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) };
setLocal(updated); onUpdated(updated);
+
+ if (removed !== undefined) {
+ pushTagUndo({
+ noteId: note.id,
+ tag: removed,
+ onUndo: async () => {
+ // Restore by re-adding the removed tag.
+ // Note: source flag is reset to 'user' on the backend by `updateAiFields` (it
+ // only takes names), which mirrors current behaviour for any user-driven
+ // tag mutation. Visual restoration matches latest local state.
+ const restoredNames = [...updated.tags.map((t) => t.name), removed.name];
+ await inboxApi.updateAiFields(note.id, { tags: restoredNames });
+ const u2 = { ...updated, tags: [...updated.tags, removed] };
+ setLocal(u2); onUpdated(u2);
+ }
+ });
+ }
+ }
+
+ function filterByTag(tagName: string): void {
+ useInbox.getState().setTagFilter(tagName);
}
async function saveIntent(next: string) {
@@ -213,15 +237,41 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
{local.tags.map((t) => (
void removeTag(t.name)}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
- padding: '2px 8px', borderRadius: 12, fontSize: 12, cursor: 'pointer'
+ padding: '2px 4px 2px 8px',
+ borderRadius: 12,
+ fontSize: 12,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 4
}}
- title={t.source === 'ai' ? 'AI 제안 — 클릭으로 제거' : '내가 추가 — 클릭으로 제거'}
>
- {t.name}{t.source === 'ai' && AI}
+ filterByTag(t.name)}
+ style={{ cursor: 'pointer' }}
+ title={`#${t.name} 노트만 보기`}
+ >
+ {t.name}{t.source === 'ai' && AI}
+
+
))}
diff --git a/src/renderer/inbox/components/TagUndoToast.tsx b/src/renderer/inbox/components/TagUndoToast.tsx
new file mode 100644
index 0000000..ff44117
--- /dev/null
+++ b/src/renderer/inbox/components/TagUndoToast.tsx
@@ -0,0 +1,85 @@
+import React, { useEffect, useState } from 'react';
+
+type UndoEntry = {
+ id: number;
+ text: string;
+ onUndo: () => void | Promise;
+};
+
+type Listener = (entry: UndoEntry | null) => void;
+
+const TOAST_DURATION_MS = 5000;
+
+let nextId = 1;
+const listeners = new Set();
+let timer: ReturnType | null = null;
+
+function broadcast(entry: UndoEntry | null): void {
+ for (const l of listeners) l(entry);
+}
+
+function clearTimerIfAny(): void {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+}
+
+export function pushTagUndo(opts: {
+ noteId: string;
+ tag: { name: string };
+ onUndo: () => void | Promise;
+}): void {
+ const entry: UndoEntry = {
+ id: nextId++,
+ text: `'#${opts.tag.name}' 제거됨 · 되돌리기`,
+ onUndo: opts.onUndo
+ };
+ clearTimerIfAny();
+ broadcast(entry);
+ timer = setTimeout(() => {
+ timer = null;
+ broadcast(null);
+ }, TOAST_DURATION_MS);
+}
+
+export function TagUndoToast(): React.ReactElement | null {
+ const [entry, setEntry] = useState(null);
+
+ useEffect(() => {
+ const l: Listener = (e) => setEntry(e);
+ listeners.add(l);
+ return () => { listeners.delete(l); };
+ }, []);
+
+ if (entry === null) return null;
+
+ return (
+ {
+ clearTimerIfAny();
+ const e = entry;
+ setEntry(null);
+ broadcast(null);
+ try { await e.onUndo(); } catch { /* swallow — best-effort restore */ }
+ }}
+ style={{
+ position: 'fixed',
+ bottom: 16,
+ left: '50%',
+ transform: 'translateX(-50%)',
+ background: '#333',
+ color: 'white',
+ padding: '8px 14px',
+ borderRadius: 6,
+ fontSize: 13,
+ cursor: 'pointer',
+ boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
+ zIndex: 1000
+ }}
+ role="alert"
+ >
+ {entry.text}
+
+ );
+}
diff --git a/src/renderer/inbox/selectFilteredNotes.ts b/src/renderer/inbox/selectFilteredNotes.ts
new file mode 100644
index 0000000..7e679f7
--- /dev/null
+++ b/src/renderer/inbox/selectFilteredNotes.ts
@@ -0,0 +1,17 @@
+import type { Note } from '@shared/types';
+
+/**
+ * Pure selector for tag-based filtering.
+ *
+ * Extracted out of `store.ts` so unit tests can import it under the
+ * vitest `node` environment without pulling in `api.ts`, which touches
+ * `window.inkling` at module-load time.
+ */
+export function selectFilteredNotes(state: {
+ notes: Note[];
+ tagFilter: string | null;
+}): Note[] {
+ if (state.tagFilter === null) return state.notes;
+ const target = state.tagFilter;
+ return state.notes.filter((n) => n.tags.some((t) => t.name === target));
+}
diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts
index 2581906..65641c4 100644
--- a/src/renderer/inbox/store.ts
+++ b/src/renderer/inbox/store.ts
@@ -2,16 +2,20 @@ import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';
+export { selectFilteredNotes } from './selectFilteredNotes.js';
+
interface InboxState {
notes: Note[];
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
loading: boolean;
+ tagFilter: string | null;
loadInitial: () => Promise;
refreshMeta: () => Promise;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
+ setTagFilter: (tag: string | null) => void;
}
const emptyContinuity: WeeklyContinuity = {
@@ -25,6 +29,7 @@ export const useInbox = create((set, get) => ({
pendingCount: 0,
ollamaStatus: { ok: true },
loading: false,
+ tagFilter: null,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([
@@ -55,5 +60,8 @@ export const useInbox = create((set, get) => ({
},
removeNote(id) {
set({ notes: get().notes.filter((n) => n.id !== id) });
+ },
+ setTagFilter(tag) {
+ set({ tagFilter: tag });
}
}));
diff --git a/tests/unit/store.tagFilter.test.ts b/tests/unit/store.tagFilter.test.ts
new file mode 100644
index 0000000..e4f6765
--- /dev/null
+++ b/tests/unit/store.tagFilter.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { selectFilteredNotes } from '../../src/renderer/inbox/selectFilteredNotes.js';
+import type { Note } from '@shared/types';
+
+function sample(id: string, tags: string[]): Note {
+ return {
+ id,
+ rawText: 'x',
+ aiTitle: null,
+ aiSummary: null,
+ aiStatus: 'done',
+ aiError: null,
+ aiProvider: null,
+ aiGeneratedAt: null,
+ titleEditedByUser: false,
+ summaryEditedByUser: false,
+ userIntent: null,
+ intentPromptedAt: null,
+ dueDate: null,
+ dueDateEditedByUser: false,
+ createdAt: '2026-04-26T00:00:00Z',
+ updatedAt: '2026-04-26T00:00:00Z',
+ tags: tags.map((name) => ({ name, source: 'ai' as const })),
+ media: []
+ };
+}
+
+describe('selectFilteredNotes', () => {
+ it('returns all notes when filter is null', () => {
+ const notes = [sample('a', ['x']), sample('b', ['y'])];
+ expect(selectFilteredNotes({ notes, tagFilter: null }).length).toBe(2);
+ });
+
+ it('filters to notes containing the tag', () => {
+ const notes = [sample('a', ['pr', 'review']), sample('b', ['demo'])];
+ const out = selectFilteredNotes({ notes, tagFilter: 'pr' });
+ expect(out.length).toBe(1);
+ expect(out[0]!.id).toBe('a');
+ });
+
+ it('empty filter result when tag not present', () => {
+ const notes = [sample('a', ['x'])];
+ expect(selectFilteredNotes({ notes, tagFilter: 'missing' }).length).toBe(0);
+ });
+
+ it('matches against any tag of the note', () => {
+ const notes = [sample('a', ['alpha', 'beta', 'gamma'])];
+ expect(selectFilteredNotes({ notes, tagFilter: 'gamma' }).length).toBe(1);
+ });
+});