From 96174f84c9d7d384a8e234db090c28f21d58490c Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 10:54:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(app):=20Sidebar=20=ED=86=B5=ED=95=A9=20+?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=203=ED=83=AD=20+=20Cmd+B=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=95=ED=82=A4=20+=20PromotionBanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/App.tsx | 32 +++++++++++++++++---- tests/unit/App.test.tsx | 57 ++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 04f6c71..9466a93 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -16,6 +16,8 @@ import { SettingsPage } from './components/SettingsPage.js'; import { OnboardingWizard } from './components/OnboardingWizard.js'; import { SearchBox } from './components/SearchBox.js'; import { ReviewView } from './components/ReviewView.js'; +import { Sidebar } from './components/Sidebar.js'; +import { PromotionBanner } from './components/PromotionBanner.js'; import type { InboxView } from './store.js'; // QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl. @@ -51,6 +53,23 @@ export function App(): React.ReactElement { })(); }, []); + useEffect(() => { + void useInbox.getState().loadNotebooks(); + void useInbox.getState().loadPromotionCandidates(); + }, []); + + useEffect(() => { + const isMac = /Mac/i.test(navigator.platform); + const handler = (e: KeyboardEvent) => { + if (e.key === 'b' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) { + e.preventDefault(); + useInbox.getState().toggleSidebar(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + useEffect(() => { void loadInitial(); const unsubNote = inboxApi.onNoteUpdated((note) => { @@ -95,7 +114,9 @@ export function App(): React.ReactElement { }); return ( - <> +
+ +

Inkling

@@ -103,7 +124,6 @@ export function App(): React.ReactElement { [ { key: 'inbox', label: 'Inbox', count: counts.active }, { key: 'completed', label: '완료', count: counts.completed }, - { key: 'archived', label: '보관', count: counts.archived }, { key: 'trash', label: '휴지통', count: counts.trashed } ] as const ).map((t) => ( @@ -153,7 +173,7 @@ export function App(): React.ReactElement { ⚙
-
+
{!showTrash && ( <> {/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출. @@ -169,6 +189,7 @@ export function App(): React.ReactElement { + )} {tagFilter !== null && ( @@ -237,8 +258,9 @@ export function App(): React.ReactElement { )} )} -
+
- +
+
); } diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index cc9bed1..1079965 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -4,6 +4,9 @@ import '@testing-library/jest-dom/vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; vi.mock('../../src/renderer/inbox/api.js', () => ({ + notebookApi: { + list: vi.fn(async () => []) + }, inboxApi: { listNotes: vi.fn(async () => []), listByStatus: vi.fn(async () => []), @@ -66,7 +69,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ // v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출. getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })), setVisionModel: vi.fn(async () => ({ ok: true as const })), - refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })) + refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })), + // v0.4 Task 15 — loadPromotionCandidates 초기화 stub. + listPromotionCandidates: vi.fn(async () => []), + getPromotionDismissedTags: vi.fn(async () => []), + getPromotionSnoozeUntil: vi.fn(async () => 0) } })); @@ -81,7 +88,8 @@ describe('App — settings view', () => { view: 'inbox', counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, showSettings: false, showTrash: false, - notes: [], trashNotes: [], trashCount: 0 + notes: [], trashNotes: [], trashCount: 0, + sidebarVisible: false, notebooks: [], promotionCandidates: [] }); }); @@ -114,14 +122,15 @@ describe('App — settings view', () => { }); }); -describe('App header — 4 tabs', () => { +describe('App header — 3 tabs (v0.4)', () => { beforeEach(() => { cleanup(); useInbox.setState({ view: 'inbox', counts: { active: 5, completed: 3, archived: 2, trashed: 1 }, notes: [], trashNotes: [], trashCount: 0, - showTrash: false, showSettings: false + showTrash: false, showSettings: false, + sidebarVisible: false, notebooks: [], promotionCandidates: [] }); // loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입 // 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신. @@ -129,35 +138,48 @@ describe('App header — 4 tabs', () => { vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, trashed: 1 }); }); - it('renders 4 tabs with counts', async () => { + it('renders 3 tabs (Inbox/완료/휴지통) with counts', async () => { render(); expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument(); - // v0.4 — archived count 는 IPC 응답에서 제거됨 → store 가 0 fallback. 보관 탭은 Task 15 에서 제거 예정. - expect(screen.getByRole('tab', { name: /보관\(0\)/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument(); }); + it('보관 탭이 헤더에 없음', async () => { + render(); + await screen.findByRole('tab', { name: /Inbox/ }); + expect(screen.queryByRole('tab', { name: /보관/ })).toBeNull(); + }); + it('clicking 완료 tab sets view=completed', async () => { render(); fireEvent.click(await screen.findByRole('tab', { name: /완료/ })); expect(useInbox.getState().view).toBe('completed'); }); - it('aria-selected reflects current view', async () => { - useInbox.setState({ view: 'archived' }); - render(); - const archivedBtn = await screen.findByRole('tab', { name: /보관/ }); - expect(archivedBtn.getAttribute('aria-selected')).toBe('true'); - const inboxBtn = screen.getByRole('tab', { name: /Inbox/ }); - expect(inboxBtn.getAttribute('aria-selected')).toBe('false'); - }); - it('inbox tab has aria-selected="true" when active', async () => { render(); const inboxTab = await screen.findByRole('tab', { name: /Inbox/ }); expect(inboxTab).toHaveAttribute('aria-selected', 'true'); }); + + it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => { + const initialVisible = useInbox.getState().sidebarVisible; + render(); + await screen.findByRole('tab', { name: /Inbox/ }); + // jsdom 에서 navigator.platform = '' → isMac=false → ctrlKey 로 판단. + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true })); + // toggleSidebar 가 호출되면 sidebarVisible 이 반전됨. + expect(useInbox.getState().sidebarVisible).toBe(!initialVisible); + }); + + it('Sidebar 컴포넌트가 렌더 트리에 포함됨 (sidebarVisible=true)', async () => { + useInbox.setState({ sidebarVisible: true, notebooks: [] }); + render(); + await screen.findByRole('tab', { name: /Inbox/ }); + // Sidebar renders an