feat(cue): IdentityCounter + tray refresh — 오늘 N번 잡아뒀다
F4-C·F (cue 강화) — Inkling 의 두 표면에 정체성 신호를 추가. F4-C 환경 앵커 (tray): - nativeImage 색 변경은 미지원이므로 색 뱃지 대신 tooltip + 메뉴 첫 비활성 라벨로 대체. tooltip 은 항상 `Inkling — 오늘 N`, 메뉴 첫 항목은 N>0 일 때 `오늘 N번 잡아둠` (비활성). - `tray.ts` 가 `refreshTray(todayCount)` 를 export 하여 main 이 60s interval + AiWorker.onUpdate hook 에서 갱신을 트리거. - N=0 일 때는 라벨을 띄우지 않아 메뉴가 자연스럽게 시작. F4-F 정체성 고리 (Inbox 헤더): - ContinuityBadge 옆에 새 IdentityCounter 컴포넌트. - N>0 → `오늘 N번 잡아뒀다` (정체성 강화 카피). - N=0 → `오늘은 처음 한 줄?` (priming 카피로 첫 캡처 유도). - 갱신은 `loadInitial` / `refreshMeta` (focus + note:updated) 경로 공유 — 별도 IPC subscription 없음. Wiring: - `NoteRepository.countToday()` 를 `inbox:todayCount` IPC 로 노출. - preload bridge `getTodayCount`, `InboxApi.getTodayCount()` 타입. - 스토어에 `todayCount: number` 필드 추가, 두 메타 fetch 경로 모두에서 갱신. 스키마 변경 없음. 197/197 unit pass, 1/1 e2e pass.
This commit is contained in:
@@ -22,7 +22,7 @@ import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
||||
import {
|
||||
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
||||
} from './windows/quickCaptureWindow.js';
|
||||
import { createTray } from './tray.js';
|
||||
import { createTray, refreshTray } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
@@ -67,7 +67,11 @@ app.whenReady().then(async () => {
|
||||
void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record<string, unknown>));
|
||||
|
||||
const worker = new AiWorker(repo, provider, {
|
||||
onUpdate: (note) => pushNoteUpdated(getInboxWindow, note),
|
||||
onUpdate: (note) => {
|
||||
pushNoteUpdated(getInboxWindow, note);
|
||||
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
|
||||
refreshTray(repo.countToday());
|
||||
},
|
||||
logger
|
||||
});
|
||||
|
||||
@@ -282,6 +286,14 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
);
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
||||
refreshTray(repo.countToday());
|
||||
const trayInterval = setInterval(() => {
|
||||
refreshTray(repo.countToday());
|
||||
}, 60_000);
|
||||
app.on('before-quit', () => { clearInterval(trayInterval); });
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:continuity', () => deps.continuity.get());
|
||||
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
|
||||
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
|
||||
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -3,24 +3,28 @@ import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
|
||||
const { app, Tray, Menu, nativeImage } = electron;
|
||||
|
||||
let tray: TrayType | null = null;
|
||||
let _showInbox: () => void = () => {};
|
||||
let _showCapture: () => void = () => {};
|
||||
let _runBackup: () => void = () => {};
|
||||
let _runExport: () => void = () => {};
|
||||
let _runImport: () => void = () => {};
|
||||
let _runSync: () => void = () => {};
|
||||
let _todayCount = 0;
|
||||
|
||||
function buildMenu(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void,
|
||||
runExport: () => void,
|
||||
runImport: () => void,
|
||||
runSync: () => void
|
||||
) {
|
||||
const items: MenuItemConstructorOptions[] = [
|
||||
{ label: '보관한 메모 보기', click: showInbox },
|
||||
{ label: '한 줄 적기', click: showCapture },
|
||||
{ type: 'separator' },
|
||||
{ label: '지금 백업', click: runBackup },
|
||||
{ label: '내보내기...', click: runExport },
|
||||
{ label: '백업에서 복원...', click: runImport },
|
||||
{ label: '지금 동기화', click: runSync }
|
||||
];
|
||||
function buildMenu() {
|
||||
const items: MenuItemConstructorOptions[] = [];
|
||||
// F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작.
|
||||
if (_todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false });
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: '보관한 메모 보기', click: _showInbox });
|
||||
items.push({ label: '한 줄 적기', click: _showCapture });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '지금 백업', click: _runBackup });
|
||||
items.push({ label: '내보내기...', click: _runExport });
|
||||
items.push({ label: '백업에서 복원...', click: _runImport });
|
||||
items.push({ label: '지금 동기화', click: _runSync });
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
@@ -50,10 +54,27 @@ export function createTray(
|
||||
runImport: () => void,
|
||||
runSync: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
_runBackup = runBackup;
|
||||
_runExport = runExport;
|
||||
_runImport = runImport;
|
||||
_runSync = runSync;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Inkling');
|
||||
tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport, runImport, runSync));
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
tray.on('click', showInbox);
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신.
|
||||
* `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출.
|
||||
*/
|
||||
export function refreshTray(todayCount: number): void {
|
||||
_todayCount = todayCount;
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const api: InklingApi = {
|
||||
getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
|
||||
getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'),
|
||||
getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'),
|
||||
getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'),
|
||||
onNoteUpdated: (cb) => {
|
||||
const listener = (_e: unknown, note: Note) => cb(note);
|
||||
ipcRenderer.on('note:updated', listener);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { inboxApi } from './api.js';
|
||||
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
|
||||
import { NoteCard } from './components/NoteCard.js';
|
||||
import { ContinuityBadge } from './components/ContinuityBadge.js';
|
||||
import { IdentityCounter } from './components/IdentityCounter.js';
|
||||
import { PendingBanner } from './components/PendingBanner.js';
|
||||
import { OllamaBanner } from './components/OllamaBanner.js';
|
||||
import { RecoveryToast } from './components/RecoveryToast.js';
|
||||
@@ -41,7 +42,10 @@ export function App(): React.ReactElement {
|
||||
<>
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<ContinuityBadge />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
</div>
|
||||
</div>
|
||||
<main className="main">
|
||||
<OllamaBanner />
|
||||
|
||||
18
src/renderer/inbox/components/IdentityCounter.tsx
Normal file
18
src/renderer/inbox/components/IdentityCounter.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
/**
|
||||
* F4-F 정체성 고리 — 헤더에 "오늘 N번 잡아뒀다" identity-reinforcement copy.
|
||||
* count = 0 일 때는 priming 카피 ("오늘은 처음 한 줄?") 로 첫 캡처를 유도.
|
||||
*/
|
||||
export function IdentityCounter(): React.ReactElement {
|
||||
const todayCount = useInbox((s) => s.todayCount);
|
||||
if (todayCount === 0) {
|
||||
return <div style={{ fontSize: 12, color: '#999' }}>오늘은 처음 한 줄?</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ fontSize: 12, color: '#236b1a' }}>
|
||||
오늘 <b>{todayCount}</b>번 잡아뒀다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface InboxState {
|
||||
continuity: WeeklyContinuity;
|
||||
pendingCount: number;
|
||||
ollamaStatus: { ok: boolean; reason?: string };
|
||||
todayCount: number;
|
||||
loading: boolean;
|
||||
tagFilter: string | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
@@ -28,25 +29,28 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
todayCount: 0,
|
||||
loading: false,
|
||||
tagFilter: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus()
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus()
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount });
|
||||
},
|
||||
upsertNote(note) {
|
||||
const i = get().notes.findIndex((n) => n.id === note.id);
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface InboxApi {
|
||||
getContinuity(): Promise<WeeklyContinuity>;
|
||||
getPendingCount(): Promise<number>;
|
||||
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
|
||||
getTodayCount(): Promise<number>;
|
||||
onNoteUpdated(cb: (note: Note) => void): () => void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user