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); + }); +});