From aad9d403ce7f8717002d2786eba3a7fc0c86a1f1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:25:13 +0900 Subject: [PATCH 1/3] feat(inbox): tagFilter store + pure selectFilteredNotes + tests Adds `tagFilter: string | null` and `setTagFilter` to the inbox store, plus an extracted pure `selectFilteredNotes` selector so unit tests can import it under vitest's `node` environment without dragging `api.ts` (which touches `window.inkling` at module load). Tests cover four cases: null filter passes through, single-tag match, no-match empty result, and any-tag-matches semantics. F2 dogfood feedback step 1/3. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/selectFilteredNotes.ts | 17 ++++++++ src/renderer/inbox/store.ts | 8 ++++ tests/unit/store.tagFilter.test.ts | 50 +++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/renderer/inbox/selectFilteredNotes.ts create mode 100644 tests/unit/store.tagFilter.test.ts 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); + }); +}); -- 2.49.1 From 8373f06045d5ddda6b6c340b02060f30e6a056ed Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:25:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(inbox):=20tag=20chip=20click=20=3D=20f?= =?UTF-8?q?ilter,=20separate=20=C3=97=20button=20+=20undo=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the tag chip into two actions per F2 dogfood feedback: - short click on chip text โ†’ applies the tag to the inbox filter (Inbox header shows "๐Ÿ”Ž ํ•„ํ„ฐ: #tag (n๊ฐœ)" banner with โœ• ํ•ด์ œ button) - ร— button on chip โ†’ immediately removes the tag and surfaces a module-level pub/sub undo toast for 5 seconds; clicking the toast restores the tag `TagUndoToast` is a tiny self-contained component: `pushTagUndo()` from NoteCard publishes an entry, the mounted `` near the end of `` subscribes and renders it. Auto-dismiss after 5 s, click-to-undo cancels the timer and runs the restore callback. AI vs user tags share the same behaviour โ€” only the chip background colour distinguishes them, matching the F2 decision table. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/App.tsx | 53 +++++++++++- src/renderer/inbox/components/NoteCard.tsx | 62 ++++++++++++-- .../inbox/components/TagUndoToast.tsx | 85 +++++++++++++++++++ 3 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/renderer/inbox/components/TagUndoToast.tsx 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} +
+ ); +} -- 2.49.1 From c4a79854562426e585f4b33e6304ddd6fd3e43d0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 11:25:31 +0900 Subject: [PATCH 3/3] docs(spec): promote F2 tag click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks F2 in dogfood-feedback as ๐Ÿš€ promoted and adds the standalone spec at docs/superpowers/specs/2026-04-26-f2-tag-click.md capturing the mini-brainstorm decisions, scope, and follow-ups (multi-tag filter, rename/merge, source preservation on undo). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-25-dogfood-feedback.md | 4 ++- .../specs/2026-04-26-f2-tag-click.md | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-04-26-f2-tag-click.md 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 ๋กœ ์ฒ˜๋ฆฌ) -- 2.49.1