diff --git a/src/preload/index.ts b/src/preload/index.ts index 7a35600..f432856 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,10 +27,16 @@ const api: InklingApi = { getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'), listExpired: () => ipcRenderer.invoke('inbox:listExpired'), trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }), + ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'), onNoteUpdated: (cb) => { const listener = (_e: unknown, note: Note) => cb(note); ipcRenderer.on('note:updated', listener); return () => ipcRenderer.off('note:updated', listener); + }, + onOllamaStatus: (cb) => { + const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status); + ipcRenderer.on('ollama:status', listener); + return () => ipcRenderer.off('ollama:status', listener); } } }; diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index ae1cdcb..37803bf 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -22,13 +22,16 @@ export function App(): React.ReactElement { useEffect(() => { void loadInitial(); - const unsub = inboxApi.onNoteUpdated((note) => { + const unsubNote = inboxApi.onNoteUpdated((note) => { upsertNote(note); void refreshMeta(); }); + const unsubOllama = inboxApi.onOllamaStatus((status) => { + useInbox.setState({ ollamaStatus: status }); + }); const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); - return () => { unsub(); window.removeEventListener('focus', onFocus); }; + return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; }, [loadInitial, refreshMeta, upsertNote]); const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index c27ed76..45d9db3 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -30,6 +30,7 @@ interface InboxState { loadExpired: () => Promise; trashExpiredBatch: (ids: string[]) => Promise; snoozeExpired: () => void; + recheckOllama: () => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -167,5 +168,9 @@ export const useInbox = create((set, get) => ({ const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; const nextKstMidnight = kstMidnightFloor + 86_400_000; set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + }, + async recheckOllama() { + const status = await inboxApi.ollamaRecheck(); + set({ ollamaStatus: status }); } })); diff --git a/src/shared/types.ts b/src/shared/types.ts index 33cca70..1b31125 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -79,7 +79,9 @@ export interface InboxApi { getTrashCount(): Promise; listExpired(): Promise; trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>; + ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>; onNoteUpdated(cb: (note: Note) => void): () => void; + onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; } export interface InklingApi { diff --git a/tests/unit/store.ollama.test.ts b/tests/unit/store.ollama.test.ts new file mode 100644 index 0000000..c205d44 --- /dev/null +++ b/tests/unit/store.ollama.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockApi = { + listNotes: vi.fn(async () => []), + listTrash: vi.fn(async () => []), + getTrashCount: vi.fn(async () => 0), + getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + restoreNote: vi.fn(async () => {}), + permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), + emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), + deleteNote: vi.fn(async () => {}), + onNoteUpdated: vi.fn(() => () => {}), + updateAiFields: vi.fn(async () => {}), + setDueDate: vi.fn(async () => {}), + setIntent: vi.fn(async () => {}), + dismissIntent: vi.fn(async () => {}), + listExpired: vi.fn(async () => []), + trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })), + ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })), + onOllamaStatus: vi.fn(() => () => {}) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +describe('useInbox — ollama (v0.2.3 #1)', () => { + beforeEach(async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + notes: [], trashNotes: [], trashCount: 0, showTrash: false, + loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, + ollamaStatus: { ok: false, reason: 'refused' }, + continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }, + expiredCandidates: [], expiredSnoozeUntilMs: null + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + it('recheckOllama calls inboxApi.ollamaRecheck and updates ollamaStatus', async () => { + mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: true }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + await useInbox.getState().recheckOllama(); + expect(mockApi.ollamaRecheck).toHaveBeenCalledTimes(1); + expect(useInbox.getState().ollamaStatus).toEqual({ ok: true }); + }); + + it('recheckOllama propagates failure status', async () => { + mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: false, reason: 'timeout' }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + await useInbox.getState().recheckOllama(); + expect(useInbox.getState().ollamaStatus).toEqual({ ok: false, reason: 'timeout' }); + }); +});