diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 59bb49e..e67ae3e 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -191,8 +191,7 @@ export class CaptureService { /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */ async markRecallOpened(noteId: string): Promise<{ note: Note }> { - const before = this.repo.findById(noteId); - if (!before) throw new Error(`note not found: ${noteId}`); + if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`); this.repo.markRecallOpened(noteId, new Date().toISOString()); if (this.deps.telemetry) { await this.deps.telemetry.emit({ diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index 706b884..46fd9af 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -184,6 +184,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null; return ( + // id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
{formatted}
diff --git a/src/renderer/inbox/components/RecallBanner.tsx b/src/renderer/inbox/components/RecallBanner.tsx index 231d5e3..c0436d8 100644 --- a/src/renderer/inbox/components/RecallBanner.tsx +++ b/src/renderer/inbox/components/RecallBanner.tsx @@ -1,15 +1,19 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useInbox } from '../store.js'; import { inboxApi } from '../api.js'; export function RecallBanner(): React.ReactElement | null { const candidate = useInbox((s) => s.recallCandidate); const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs); - const shownIds = useInbox((s) => s.recallShownIds); const openRecall = useInbox((s) => s.openRecall); const dismissRecallNote = useInbox((s) => s.dismissRecallNote); const snoozeRecall = useInbox((s) => s.snoozeRecall); + // i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X) + // 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장. + // 컴포넌트 언마운트/리마운트 시 reset (session-local 의도). + const shownIdsRef = useRef>(new Set()); + // ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render const [, setTick] = useState(0); useEffect(() => { @@ -20,20 +24,21 @@ export function RecallBanner(): React.ReactElement | null { return () => clearTimeout(t); }, [snoozeUntilMs]); - // first-render emit recall_shown (per-session 1회 per note) + // first-render emit recall_shown (per-banner-lifetime 1회 per note) useEffect(() => { if (!candidate) return; if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return; - if (shownIds.has(candidate.id)) return; + if (shownIdsRef.current.has(candidate.id)) return; void inboxApi.emitRecallShown(candidate.id); - useInbox.setState({ recallShownIds: new Set([...shownIds, candidate.id]) }); - }, [candidate, snoozeUntilMs, shownIds]); + shownIdsRef.current.add(candidate.id); + }, [candidate, snoozeUntilMs]); if (candidate === null) return null; if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null; const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt); - const title = candidate.aiTitle ?? candidate.rawText.slice(0, 60); + // m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지 + const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)'; function onOpen() { void openRecall(candidate!.id); diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 7ff7c97..8e7c708 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -20,7 +20,6 @@ interface InboxState { failedCount: number; recallCandidate: Note | null; recallSnoozeUntilMs: number | null; - recallShownIds: Set; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -63,7 +62,6 @@ export const useInbox = create((set, get) => ({ failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null, - recallShownIds: new Set(), async loadInitial() { set({ loading: true }); const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([ @@ -210,7 +208,8 @@ export const useInbox = create((set, get) => ({ async dismissRecallNote(id) { await inboxApi.dismissRecall(id); const recallCandidate = await inboxApi.listRecallCandidate(); - set({ recallCandidate }); + // m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear + set({ recallCandidate, recallSnoozeUntilMs: null }); }, async snoozeRecall() { const KST_OFFSET_MS = 9 * 60 * 60 * 1000; @@ -219,6 +218,8 @@ export const useInbox = create((set, get) => ({ const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; const nextKstMidnight = kstMidnightFloor + 86_400_000; set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + // m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시 + // snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적). const candidate = get().recallCandidate; if (candidate) { await inboxApi.emitRecallSnoozed(candidate.id); diff --git a/tests/unit/store.recall.test.ts b/tests/unit/store.recall.test.ts index 75175e6..eb67a18 100644 --- a/tests/unit/store.recall.test.ts +++ b/tests/unit/store.recall.test.ts @@ -46,7 +46,6 @@ describe('store recall actions', () => { useInbox.setState({ recallCandidate: null, recallSnoozeUntilMs: null, - recallShownIds: new Set() } as Parameters[0]); });