diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx
new file mode 100644
index 0000000..deed729
--- /dev/null
+++ b/src/renderer/inbox/App.tsx
@@ -0,0 +1,53 @@
+import React, { useEffect, useState } from 'react';
+import { useInbox } from './store.js';
+import { inboxApi } from './api.js';
+import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
+import { NoteCard } from './components/NoteCard.js';
+import { ContinuityBadge } from './components/ContinuityBadge.js';
+import { PendingBanner } from './components/PendingBanner.js';
+import { OllamaBanner } from './components/OllamaBanner.js';
+import { RecoveryToast } from './components/RecoveryToast.js';
+
+export function App(): React.ReactElement {
+ const { notes, loading, loadInitial, refreshMeta, upsertNote, removeNote, continuity } = useInbox();
+ const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
+
+ useEffect(() => {
+ void loadInitial();
+ const unsub = inboxApi.onNoteUpdated((note) => {
+ upsertNote(note);
+ void refreshMeta();
+ });
+ const onFocus = () => { void refreshMeta(); };
+ window.addEventListener('focus', onFocus);
+ return () => { unsub(); window.removeEventListener('focus', onFocus); };
+ }, [loadInitial, refreshMeta, upsertNote]);
+
+ const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
+
+ return (
+ <>
+
+
Inkling
+
+
+
+
+ { markRecoveryDismissed(); setRecoveryDismissed(true); }}
+ />
+
+ {loading && notes.length === 0 ? (
+ 불러오는 중…
+ ) : notes.length === 0 ? (
+ 첫 기억을 구출해보세요. Ctrl+Shift+J
+ ) : (
+ notes.map((n) => (
+ removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
+ ))
+ )}
+
+ >
+ );
+}
diff --git a/src/renderer/inbox/api.ts b/src/renderer/inbox/api.ts
new file mode 100644
index 0000000..3a78192
--- /dev/null
+++ b/src/renderer/inbox/api.ts
@@ -0,0 +1,2 @@
+import type { InboxApi } from '@shared/types';
+export const inboxApi: InboxApi = window.inkling.inbox;
diff --git a/src/renderer/inbox/components/ContinuityBadge.tsx b/src/renderer/inbox/components/ContinuityBadge.tsx
new file mode 100644
index 0000000..a02d330
--- /dev/null
+++ b/src/renderer/inbox/components/ContinuityBadge.tsx
@@ -0,0 +1,2 @@
+import React from 'react';
+export function ContinuityBadge() { return null; }
diff --git a/src/renderer/inbox/components/EditableField.tsx b/src/renderer/inbox/components/EditableField.tsx
new file mode 100644
index 0000000..4a5910a
--- /dev/null
+++ b/src/renderer/inbox/components/EditableField.tsx
@@ -0,0 +1,9 @@
+import React, { CSSProperties } from 'react';
+export function EditableField(props: {
+ value: string;
+ onSave: (next: string) => Promise;
+ style?: CSSProperties;
+ singleLine?: boolean;
+}): React.ReactElement {
+ return {props.value}
;
+}
diff --git a/src/renderer/inbox/components/IntentBanner.tsx b/src/renderer/inbox/components/IntentBanner.tsx
new file mode 100644
index 0000000..9796bd3
--- /dev/null
+++ b/src/renderer/inbox/components/IntentBanner.tsx
@@ -0,0 +1,2 @@
+import React from 'react';
+export function IntentBanner(_: { noteId: string; onResolved: (intentText: string | null) => void }) { return null; }
diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx
new file mode 100644
index 0000000..35618dc
--- /dev/null
+++ b/src/renderer/inbox/components/NoteCard.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+import type { Note } from '@shared/types';
+export function NoteCard({ note }: { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void }) {
+ return {note.rawText}
;
+}
diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx
new file mode 100644
index 0000000..503bb93
--- /dev/null
+++ b/src/renderer/inbox/components/OllamaBanner.tsx
@@ -0,0 +1,2 @@
+import React from 'react';
+export function OllamaBanner() { return null; }
diff --git a/src/renderer/inbox/components/PendingBanner.tsx b/src/renderer/inbox/components/PendingBanner.tsx
new file mode 100644
index 0000000..98c988e
--- /dev/null
+++ b/src/renderer/inbox/components/PendingBanner.tsx
@@ -0,0 +1,2 @@
+import React from 'react';
+export function PendingBanner() { return null; }
diff --git a/src/renderer/inbox/components/RecoveryToast.tsx b/src/renderer/inbox/components/RecoveryToast.tsx
new file mode 100644
index 0000000..9d5be96
--- /dev/null
+++ b/src/renderer/inbox/components/RecoveryToast.tsx
@@ -0,0 +1,2 @@
+import React from 'react';
+export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; }
diff --git a/src/renderer/inbox/index.html b/src/renderer/inbox/index.html
index e031d2c..8398b0a 100644
--- a/src/renderer/inbox/index.html
+++ b/src/renderer/inbox/index.html
@@ -2,11 +2,22 @@
-
+
Inkling
+
-
+
diff --git a/src/renderer/inbox/main.tsx b/src/renderer/inbox/main.tsx
new file mode 100644
index 0000000..7d27e5b
--- /dev/null
+++ b/src/renderer/inbox/main.tsx
@@ -0,0 +1,4 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App.js';
+createRoot(document.getElementById('root')!).render();
diff --git a/src/renderer/inbox/recoveryToast.ts b/src/renderer/inbox/recoveryToast.ts
new file mode 100644
index 0000000..85f926b
--- /dev/null
+++ b/src/renderer/inbox/recoveryToast.ts
@@ -0,0 +1,14 @@
+const KEY = 'inkling.recoveryDismissedAt';
+
+export function isRecoveryDismissedToday(now = new Date()): boolean {
+ const v = localStorage.getItem(KEY);
+ if (!v) return false;
+ const stored = new Date(v);
+ const kstNow = new Date(now.getTime() + 9 * 3600_000).toISOString().slice(0, 10);
+ const kstStored = new Date(stored.getTime() + 9 * 3600_000).toISOString().slice(0, 10);
+ return kstNow === kstStored;
+}
+
+export function markRecoveryDismissed(now = new Date()): void {
+ localStorage.setItem(KEY, now.toISOString());
+}
diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts
new file mode 100644
index 0000000..2581906
--- /dev/null
+++ b/src/renderer/inbox/store.ts
@@ -0,0 +1,59 @@
+import { create } from 'zustand';
+import type { Note, WeeklyContinuity } from '@shared/types';
+import { inboxApi } from './api.js';
+
+interface InboxState {
+ notes: Note[];
+ continuity: WeeklyContinuity;
+ pendingCount: number;
+ ollamaStatus: { ok: boolean; reason?: string };
+ loading: boolean;
+ loadInitial: () => Promise;
+ refreshMeta: () => Promise;
+ upsertNote: (note: Note) => void;
+ removeNote: (id: string) => void;
+}
+
+const emptyContinuity: WeeklyContinuity = {
+ weekStart: '', weekCount: 0, weekTarget: 7,
+ consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
+};
+
+export const useInbox = create((set, get) => ({
+ notes: [],
+ continuity: emptyContinuity,
+ pendingCount: 0,
+ ollamaStatus: { ok: true },
+ loading: false,
+ async loadInitial() {
+ set({ loading: true });
+ const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([
+ inboxApi.listNotes({ limit: 50 }),
+ inboxApi.getContinuity(),
+ inboxApi.getPendingCount(),
+ inboxApi.getOllamaStatus()
+ ]);
+ set({ notes, continuity, pendingCount, ollamaStatus, loading: false });
+ },
+ async refreshMeta() {
+ const [continuity, pendingCount, ollamaStatus] = await Promise.all([
+ inboxApi.getContinuity(),
+ inboxApi.getPendingCount(),
+ inboxApi.getOllamaStatus()
+ ]);
+ set({ continuity, pendingCount, ollamaStatus });
+ },
+ upsertNote(note) {
+ const i = get().notes.findIndex((n) => n.id === note.id);
+ if (i >= 0) {
+ const next = get().notes.slice();
+ next[i] = note;
+ set({ notes: next });
+ } else {
+ set({ notes: [note, ...get().notes] });
+ }
+ },
+ removeNote(id) {
+ set({ notes: get().notes.filter((n) => n.id !== id) });
+ }
+}));