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