feat(inbox): React shell + store + component stubs (v0.2)

Task 22 of the slice plan. Wires the Inbox window's renderer:
- index.html (replaces the placeholder shipped in Task 2) with
  the full layout styles + module script.
- api.ts re-exports window.inkling.inbox as a typed InboxApi.
- recoveryToast.ts persists per-day toast dismissal in
  localStorage (KST date key) so the banner never re-fires
  after the user closes it.
- store.ts: zustand store with notes, continuity, pendingCount,
  ollamaStatus, loadInitial(), refreshMeta(), upsert/remove.
- main.tsx mounts <App />.
- App.tsx orchestrates loadInitial + onNoteUpdated subscription
  + window-focus refresh, renders header / banners / note list.
- 7 component stubs (NoteCard / EditableField / IntentBanner /
  RecoveryToast / ContinuityBadge / PendingBanner / OllamaBanner)
  so the shell typechecks today; Tasks 23-28 swap each in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:16:08 +09:00
parent d4ad2f8d15
commit 6b522b31d0
13 changed files with 169 additions and 2 deletions

View File

@@ -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 (
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<ContinuityBadge />
</div>
<main className="main">
<OllamaBanner />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : notes.length === 0 ? (
<div className="empty"> . <code>Ctrl+Shift+J</code></div>
) : (
notes.map((n) => (
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
))
)}
</main>
</>
);
}

View File

@@ -0,0 +1,2 @@
import type { InboxApi } from '@shared/types';
export const inboxApi: InboxApi = window.inkling.inbox;

View File

@@ -0,0 +1,2 @@
import React from 'react';
export function ContinuityBadge() { return null; }

View File

@@ -0,0 +1,9 @@
import React, { CSSProperties } from 'react';
export function EditableField(props: {
value: string;
onSave: (next: string) => Promise<void>;
style?: CSSProperties;
singleLine?: boolean;
}): React.ReactElement {
return <div style={props.style}>{props.value}</div>;
}

View File

@@ -0,0 +1,2 @@
import React from 'react';
export function IntentBanner(_: { noteId: string; onResolved: (intentText: string | null) => void }) { return null; }

View File

@@ -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 <div style={{ background: 'white', padding: 12, marginBottom: 10, borderRadius: 8 }}>{note.rawText}</div>;
}

View File

@@ -0,0 +1,2 @@
import React from 'react';
export function OllamaBanner() { return null; }

View File

@@ -0,0 +1,2 @@
import React from 'react';
export function PendingBanner() { return null; }

View File

@@ -0,0 +1,2 @@
import React from 'react';
export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; }

View File

@@ -2,11 +2,22 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
<title>Inkling</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }
.header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: white; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 10; }
.main { max-width: 780px; margin: 0 auto; padding: 20px; }
.banner { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; }
.banner.warn { background: #fff4d6; color: #7a5a00; }
.banner.info { background: #e3f2ff; color: #0a4b80; }
.banner.recovery { background: #e9f9e4; color: #236b1a; }
.banner button { background: none; border: none; color: inherit; cursor: pointer; font-size: 13px; }
.empty { text-align: center; color: #888; padding: 60px 0; }
</style>
</head>
<body>
<div id="root"></div>
<script>document.getElementById('root').textContent = 'Inkling Inbox (renderer pending)';</script>
<script type="module" src="/src/renderer/inbox/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
createRoot(document.getElementById('root')!).render(<App />);

View File

@@ -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());
}

View File

@@ -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<void>;
refreshMeta: () => Promise<void>;
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<InboxState>((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) });
}
}));