From a25f9780ff8c6c4013cf3ad318c9e43ada52c4b8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 28 Mar 2026 23:01:42 -0400 Subject: [PATCH 1/2] feat(penpal): add sidebar view options for project ordering and filtering Adds a view options button (Filter Projects icon from Nexus) next to the + button in the sidebar header. Clicking it opens a floating panel with project sort order (A-Z / Most Recent) and show/hide empty projects toggle. The panel dismisses only on outside click. Preferences persist in localStorage with cross-tab sync. Closes PENPAL-16 Co-Authored-By: Claude Opus 4.6 --- apps/penpal/ERD.md | 7 +- apps/penpal/PRODUCT.md | 4 +- apps/penpal/TESTING.md | 1 + .../frontend/src/components/Layout.test.tsx | 133 ++++++++++++++++++ .../penpal/frontend/src/components/Layout.tsx | 89 ++++++++++-- .../frontend/src/hooks/useProjectSort.test.ts | 34 ++++- .../frontend/src/hooks/useProjectSort.ts | 29 ++-- apps/penpal/frontend/src/index.css | 61 ++++++++ 8 files changed, 333 insertions(+), 25 deletions(-) diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md index 63eaec06b..ca38fd1d1 100644 --- a/apps/penpal/ERD.md +++ b/apps/penpal/ERD.md @@ -258,15 +258,18 @@ see-also: - **E-PENPAL-MD-RENDER**: Each rendered block is tagged with `data-source-line` (1-indexed). Heading IDs use the same slugification algorithm as Go's goldmark renderer. Mermaid blocks produce `.mermaid-container` divs with `data-mermaid-source`. ← [P-PENPAL-GFM](PRODUCT.md#P-PENPAL-GFM), [P-PENPAL-MERMAID](PRODUCT.md#P-PENPAL-MERMAID) -- **E-PENPAL-HOME-SIDEBAR**: In home mode (no active project), `Layout.tsx` renders a sidebar tree with: ⌂ header with "+" add button and sort toggle, expandable workspace items (with child projects), standalone project items as top-level peers, and global "In Review" / "Recent" `NavLink`s in a global nav section. Visual dividers (`.home-section-divider`) separate the workspaces section, standalone projects section, and global navigation section; sections with no items are omitted along with their dividers. Workspace items show agent dots when any child project has `agentConnected`. Projects show: name (with branch as tooltip), agent dot, and expandable worktree children (when multiple worktrees exist). Non-main worktrees display a worktree icon; the main worktree does not. State: `expandedWorkspaces` and `expandedWorktreeProjects` `Set`. Clicking a project navigates to `/project/{qn}`. `useProjectSort` hook controls ordering; projects with zero files sort last and are visually dimmed (`.deemphasized` class). +- **E-PENPAL-HOME-SIDEBAR**: In home mode (no active project), `Layout.tsx` renders a sidebar tree with: ⌂ header with view-options button and "+" add button, expandable workspace items (with child projects), standalone project items as top-level peers, and global "In Review" / "Recent" `NavLink`s in a global nav section. Visual dividers (`.home-section-divider`) separate the workspaces section, standalone projects section, and global navigation section; sections with no items are omitted along with their dividers. Workspace items show agent dots when any child project has `agentConnected`. Projects show: name (with branch as tooltip), agent dot, and expandable worktree children (when multiple worktrees exist). Non-main worktrees display a worktree icon; the main worktree does not. State: `expandedWorkspaces` and `expandedWorktreeProjects` `Set`. Clicking a project navigates to `/project/{qn}`. `useProjectSort` hook controls ordering; when `showEmpty` is false, projects with zero files are hidden; when shown, they sort last and are visually dimmed (`.deemphasized` class). ← [P-PENPAL-HOME](PRODUCT.md#P-PENPAL-HOME), [P-PENPAL-HOME-TREE](PRODUCT.md#P-PENPAL-HOME-TREE), [P-PENPAL-HOME-PROJECT-ITEM](PRODUCT.md#P-PENPAL-HOME-PROJECT-ITEM), [P-PENPAL-HOME-NAVIGATE](PRODUCT.md#P-PENPAL-HOME-NAVIGATE) - **E-PENPAL-FE-HOME-LABEL**: When `isProjectMode` is false (home screen), the sidebar home header renders the text "Home" in a `` next to the ⌂ icon. The label inherits the header's font weight and uses `margin-right: auto` to push action buttons to the right side. ← [P-PENPAL-HOME-LABEL](PRODUCT.md#P-PENPAL-HOME-LABEL) -- **E-PENPAL-SORT**: `useProjectSort` hook uses `useSyncExternalStore` backed by localStorage key `penpal-project-sort` (default `'alpha'`). Subscribes to `storage` events for cross-tab sync. Home sidebar sorts by `localeCompare` for alpha or uses server order (sorted by `cache.ProjectsSortedByModTime()`) for recent. Projects with `fileCount === 0` always sort last and get `.deemphasized` styling. The sort toggle in the home sidebar header switches between the two modes. +- **E-PENPAL-SORT**: `useProjectSort` hook uses `useSyncExternalStore` backed by localStorage keys `penpal-project-sort` (default `'alpha'`) and `penpal-show-empty` (default `'true'`). Subscribes to `storage` events for cross-tab sync. Home sidebar sorts by `localeCompare` for alpha or uses server order (sorted by `cache.ProjectsSortedByModTime()`) for recent. When `showEmpty` is false, projects with `fileCount === 0` are filtered out entirely. When shown, they sort last and get `.deemphasized` styling. ← [P-PENPAL-SORT](PRODUCT.md#P-PENPAL-SORT) +- **E-PENPAL-VIEW-OPTIONS**: A `ViewOptionsPanel` renders as a floating panel (`.view-options-panel`) anchored below the view-options button in the sidebar home header. The panel contains a select dropdown for sort order (A->Z / Most Recent) and a checkbox toggle for show-empty-projects. The panel uses a ref and `mousedown` listener to dismiss on outside click. State changes update `useProjectSort` immediately without dismissing the panel. The view-options button uses the Filter Projects SVG icon from the Nexus icon library. + ← [P-PENPAL-VIEW-OPTIONS](PRODUCT.md#P-PENPAL-VIEW-OPTIONS) + - **E-PENPAL-HOME-DEFAULT**: `HomePage` component at route `/` renders a welcome screen with the app name and `⌘P` search hint. Opening a new tab (+ button or Cmd+T) calls `openTab('/', 'Home')`. New windows open on `/`. ← [P-PENPAL-HOME-DEFAULT](PRODUCT.md#P-PENPAL-HOME-DEFAULT) diff --git a/apps/penpal/PRODUCT.md b/apps/penpal/PRODUCT.md index 2ae769927..001407060 100644 --- a/apps/penpal/PRODUCT.md +++ b/apps/penpal/PRODUCT.md @@ -128,7 +128,9 @@ The home view is the top-level navigation state. It shows all workspaces and sta - **P-PENPAL-HOME-NAVIGATE**: Clicking a project in the home tree navigates the current tab to the project view for that project. The sidebar switches to show the project's sources. -- **P-PENPAL-SORT**: The home sidebar header includes a sort toggle that switches between alphabetical and recent-modification order for workspaces and standalone projects. The selected order persists in localStorage and syncs across tabs/windows. Projects with zero recognized files always sort last and are visually dimmed. +- **P-PENPAL-SORT**: The home sidebar header includes a view options button (filter icon) that opens a floating panel with project sort order (A->Z or Most Recent) and a toggle to show/hide empty projects. The selected sort order and show-empty preference persist in localStorage and sync across tabs/windows. Projects with zero recognized files always sort last (when shown) and are visually dimmed. + +- **P-PENPAL-VIEW-OPTIONS**: The view options button sits next to the "+" button in the sidebar header. Clicking it opens a floating panel that stays open while the user changes settings. The panel dismisses only when clicking outside it. The panel contains: a "Project order" dropdown with A->Z (default) and Most Recent options, and a "Show empty projects" toggle that defaults to on. Preferences persist in localStorage. - **P-PENPAL-HOME-DEFAULT**: Opening a new tab (via + button or Cmd+T) or a new window with no target lands on the home view. The main content area shows a welcome message with the app name and a hint to select a project. diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md index 48e78ab15..64e40baa3 100644 --- a/apps/penpal/TESTING.md +++ b/apps/penpal/TESTING.md @@ -102,6 +102,7 @@ see-also: | Source Disambiguation (P-PENPAL-SRC-DISAMBIG) | — | Layout.test.tsx | — | — | | Home Label (P-PENPAL-HOME-LABEL) | — | Layout.test.tsx | — | — | | File View Margins (P-PENPAL-VIEW-MARGINS) | — | file-view-layout.test.ts | — | — | +| View Options (P-PENPAL-VIEW-OPTIONS, P-PENPAL-SORT) | — | useProjectSort.test.ts, Layout.test.tsx | — | — | ## Known Coverage Gaps diff --git a/apps/penpal/frontend/src/components/Layout.test.tsx b/apps/penpal/frontend/src/components/Layout.test.tsx index 4ac048e41..38653140a 100644 --- a/apps/penpal/frontend/src/components/Layout.test.tsx +++ b/apps/penpal/frontend/src/components/Layout.test.tsx @@ -419,6 +419,7 @@ describe('Layout', () => { link.remove(); }); + // E-PENPAL-SIDEBAR-RESIZE: sidebar resize tests describe('sidebar resize', () => { it('renders the sidebar resize handle', () => { @@ -605,4 +606,136 @@ describe('Layout', () => { const breadcrumbBar = worktreeRow!.closest('.breadcrumb-bar'); expect(breadcrumbBar).toBeNull(); }); + + // E-PENPAL-VIEW-OPTIONS: view options button renders and opens panel + it('renders view options button in home sidebar', async () => { + render( + + + , + ); + + expect(screen.getByLabelText('View options')).toBeInTheDocument(); + }); + + it('opens view options panel on click', async () => { + render( + + + , + ); + + const btn = screen.getByLabelText('View options'); + await act(async () => { + fireEvent.click(btn); + }); + + expect(screen.getByText('Project order')).toBeInTheDocument(); + expect(screen.getByText('Show empty projects')).toBeInTheDocument(); + }); + + it('changes sort order via view options panel', async () => { + vi.mocked(api.listProjects).mockResolvedValue([ + { + name: 'bravo', + qualifiedName: 'bravo', + workspace: '', + projectPath: '/tmp/bravo', + origin: 'standalone' as const, + badges: [], + fileCount: 3, + lastModified: '2026-01-02T00:00:00Z', + }, + { + name: 'alpha', + qualifiedName: 'alpha', + workspace: '', + projectPath: '/tmp/alpha', + origin: 'standalone' as const, + badges: [], + fileCount: 5, + lastModified: '2026-01-01T00:00:00Z', + }, + ]); + + render( + + + , + ); + + // Wait for projects to load + await screen.findByText('alpha'); + + // Default A→Z: alpha should appear before bravo + const sidebar = screen.getByTestId('sidebar'); + const items = sidebar.querySelectorAll('.tree-item .label'); + const labels = Array.from(items).map((el) => el.textContent); + const alphaIdx = labels.indexOf('alpha'); + const bravoIdx = labels.indexOf('bravo'); + expect(alphaIdx).toBeLessThan(bravoIdx); + + // Open view options and switch to "Most Recent" + const btn = screen.getByLabelText('View options'); + await act(async () => { + fireEvent.click(btn); + }); + const select = screen.getByDisplayValue('A→Z'); + await act(async () => { + fireEvent.change(select, { target: { value: 'recent' } }); + }); + + expect(localStorage.getItem('penpal-project-sort')).toBe('recent'); + }); + + it('hides empty projects when toggle is off', async () => { + vi.mocked(api.listProjects).mockResolvedValue([ + { + name: 'has-files', + qualifiedName: 'has-files', + workspace: '', + projectPath: '/tmp/has-files', + origin: 'standalone' as const, + badges: [], + fileCount: 5, + lastModified: '2026-01-01T00:00:00Z', + }, + { + name: 'empty-project', + qualifiedName: 'empty-project', + workspace: '', + projectPath: '/tmp/empty-project', + origin: 'standalone' as const, + badges: [], + fileCount: 0, + lastModified: '2026-01-01T00:00:00Z', + }, + ]); + + render( + + + , + ); + + // Wait for projects to load — both should be visible initially + await screen.findByText('has-files'); + expect(screen.getByText('empty-project')).toBeInTheDocument(); + + // Open view options and uncheck "Show empty projects" + const btn = screen.getByLabelText('View options'); + await act(async () => { + fireEvent.click(btn); + }); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + await act(async () => { + fireEvent.click(checkbox); + }); + + // Empty project should be hidden + expect(screen.queryByText('empty-project')).not.toBeInTheDocument(); + // Non-empty project should still be visible + expect(screen.getByText('has-files')).toBeInTheDocument(); + }); }); diff --git a/apps/penpal/frontend/src/components/Layout.tsx b/apps/penpal/frontend/src/components/Layout.tsx index cf95a922a..40f0d134d 100644 --- a/apps/penpal/frontend/src/components/Layout.tsx +++ b/apps/penpal/frontend/src/components/Layout.tsx @@ -315,7 +315,24 @@ export default function Layout() { useCallback(() => {}, []), ); - const { sortOrder, toggle: toggleSort } = useProjectSort(); + const { sortOrder, setSortOrder, showEmpty, setShowEmpty } = useProjectSort(); + + // E-PENPAL-VIEW-OPTIONS: view options panel state + const [showViewOptions, setShowViewOptions] = useState(false); + const viewOptionsPanelRef = useRef(null); + + // Close view options panel on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (viewOptionsPanelRef.current && !viewOptionsPanelRef.current.contains(e.target as Node)) { + setShowViewOptions(false); + } + } + if (showViewOptions) { + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + } + }, [showViewOptions]); // Group projects by workspace const workspaceProjects = useMemo( @@ -329,9 +346,12 @@ export default function Layout() { return 0; }, [sortOrder]); + // E-PENPAL-VIEW-OPTIONS: filter empty projects when showEmpty is false + const filterEmpty = useCallback((p: APIProject) => showEmpty || p.fileCount > 0, [showEmpty]); + const standaloneProjects = useMemo(() => { - return projects.filter((p) => p.origin === 'standalone').sort(projectSort); - }, [projects, sortOrder, projectSort]); + return projects.filter((p) => p.origin === 'standalone').filter(filterEmpty).sort(projectSort); + }, [projects, sortOrder, projectSort, filterEmpty]); const workspaces = useMemo(() => { const ws = [...new Set(workspaceProjects.map((p) => p.workspace))]; ws.sort((a, b) => a.localeCompare(b)); @@ -340,10 +360,15 @@ export default function Layout() { const sortedWorkspaceProjects = useMemo(() => { const map = new Map(); for (const ws of workspaces) { - map.set(ws, workspaceProjects.filter(p => p.workspace === ws).sort(projectSort)); + map.set(ws, workspaceProjects.filter(p => p.workspace === ws).filter(filterEmpty).sort(projectSort)); } return map; - }, [workspaces, workspaceProjects, projectSort]); + }, [workspaces, workspaceProjects, projectSort, filterEmpty]); + + // E-PENPAL-VIEW-OPTIONS: hide workspaces with no visible projects when showEmpty is false + const visibleWorkspaces = useMemo(() => { + return workspaces.filter(ws => (sortedWorkspaceProjects.get(ws) || []).length > 0); + }, [workspaces, sortedWorkspaceProjects]); // Listen for native menu events (tab/window shortcuts) useEffect(() => { @@ -384,7 +409,7 @@ export default function Layout() { window.removeEventListener('menu-go-back', goBack); window.removeEventListener('menu-go-forward', goForward); }; - }, [activeTabId, clearWindowFocusOnClose, closeTab, openTab, activateTab, tabs, workspaces, standaloneProjects, goBack, goForward]); + }, [activeTabId, clearWindowFocusOnClose, closeTab, openTab, activateTab, tabs, visibleWorkspaces, standaloneProjects, goBack, goForward]); // Listen for find bar toggle (Tauri menu event only — in browser, native Cmd+F works) useEffect(() => { @@ -1088,13 +1113,51 @@ export default function Layout() { <> {/* Home view sidebar */} {/* E-PENPAL-FE-HOME-LABEL: show "Home" label next to house icon on home screen */} + {/* E-PENPAL-VIEW-OPTIONS: view options popover with sort order and show-empty toggle */}
Home - +
+ + {showViewOptions && ( +
+ + +
+ )} +
{/* Workspaces */} - {workspaces.map(ws => { + {visibleWorkspaces.map(ws => { const isExpanded = expandedWorkspaces.has(ws); const wsProjects = sortedWorkspaceProjects.get(ws) || []; const hasAgent = wsProjects.some(p => p.agentConnected); @@ -1176,7 +1239,7 @@ export default function Layout() { })} {/* Divider between workspaces and standalone projects */} - {workspaces.length > 0 && standaloneProjects.length > 0 && ( + {visibleWorkspaces.length > 0 && standaloneProjects.length > 0 && (
)} @@ -1197,7 +1260,7 @@ export default function Layout() { ))} {/* Divider before global nav */} - {(workspaces.length > 0 || standaloneProjects.length > 0) && ( + {(visibleWorkspaces.length > 0 || standaloneProjects.length > 0) && (
)} diff --git a/apps/penpal/frontend/src/hooks/useProjectSort.test.ts b/apps/penpal/frontend/src/hooks/useProjectSort.test.ts index 0c4180bd8..ecbce429e 100644 --- a/apps/penpal/frontend/src/hooks/useProjectSort.test.ts +++ b/apps/penpal/frontend/src/hooks/useProjectSort.test.ts @@ -6,7 +6,7 @@ beforeEach(() => { localStorage.clear(); }); -// E-PENPAL-HOME-SIDEBAR: verifies default, toggle, persistence, and cross-instance sync. +// E-PENPAL-SORT, E-PENPAL-VIEW-OPTIONS: verifies default, toggle, persistence, showEmpty, and cross-instance sync. describe('useProjectSort', () => { it('defaults to alpha', () => { const { result } = renderHook(() => useProjectSort()); @@ -55,4 +55,36 @@ describe('useProjectSort', () => { act(() => b.current.toggle()); expect(a.current.sortOrder).toBe('alpha'); }); + + // E-PENPAL-VIEW-OPTIONS: showEmpty tests + it('defaults showEmpty to true', () => { + const { result } = renderHook(() => useProjectSort()); + expect(result.current.showEmpty).toBe(true); + }); + + it('reads stored showEmpty preference', () => { + localStorage.setItem('penpal-show-empty', 'false'); + const { result } = renderHook(() => useProjectSort()); + expect(result.current.showEmpty).toBe(false); + }); + + it('setShowEmpty persists to localStorage', () => { + const { result } = renderHook(() => useProjectSort()); + + act(() => result.current.setShowEmpty(false)); + expect(result.current.showEmpty).toBe(false); + expect(localStorage.getItem('penpal-show-empty')).toBe('false'); + + act(() => result.current.setShowEmpty(true)); + expect(result.current.showEmpty).toBe(true); + expect(localStorage.getItem('penpal-show-empty')).toBe('true'); + }); + + it('showEmpty syncs across instances', () => { + const { result: a } = renderHook(() => useProjectSort()); + const { result: b } = renderHook(() => useProjectSort()); + + act(() => a.current.setShowEmpty(false)); + expect(b.current.showEmpty).toBe(false); + }); }); diff --git a/apps/penpal/frontend/src/hooks/useProjectSort.ts b/apps/penpal/frontend/src/hooks/useProjectSort.ts index 547609e85..1d264789a 100644 --- a/apps/penpal/frontend/src/hooks/useProjectSort.ts +++ b/apps/penpal/frontend/src/hooks/useProjectSort.ts @@ -2,7 +2,8 @@ import { useCallback, useSyncExternalStore } from 'react'; export type ProjectSortOrder = 'alpha' | 'recent'; -const STORAGE_KEY = 'penpal-project-sort'; +const SORT_KEY = 'penpal-project-sort'; +const SHOW_EMPTY_KEY = 'penpal-show-empty'; let listeners: Array<() => void> = []; @@ -14,7 +15,7 @@ function subscribe(listener: () => void) { listeners = [...listeners, listener]; // Cross-tab sync via storage event function handleStorage(e: StorageEvent) { - if (e.key === STORAGE_KEY) listener(); + if (e.key === SORT_KEY || e.key === SHOW_EMPTY_KEY) listener(); } window.addEventListener('storage', handleStorage); return () => { @@ -23,18 +24,30 @@ function subscribe(listener: () => void) { }; } -function getSnapshot(): ProjectSortOrder { - const stored = localStorage.getItem(STORAGE_KEY); +function getSortSnapshot(): ProjectSortOrder { + const stored = localStorage.getItem(SORT_KEY); if (stored === 'alpha' || stored === 'recent') return stored; return 'alpha'; } -// E-PENPAL-HOME-SIDEBAR: useSyncExternalStore backed by localStorage with cross-tab sync for project ordering. +function getShowEmptySnapshot(): boolean { + const stored = localStorage.getItem(SHOW_EMPTY_KEY); + if (stored === 'false') return false; + return true; // default true +} + +// E-PENPAL-SORT, E-PENPAL-VIEW-OPTIONS: useSyncExternalStore backed by localStorage with cross-tab sync for project ordering and show-empty toggle. export function useProjectSort() { - const sortOrder = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const sortOrder = useSyncExternalStore(subscribe, getSortSnapshot, getSortSnapshot); + const showEmpty = useSyncExternalStore(subscribe, getShowEmptySnapshot, getShowEmptySnapshot); const setSortOrder = useCallback((order: ProjectSortOrder) => { - localStorage.setItem(STORAGE_KEY, order); + localStorage.setItem(SORT_KEY, order); + emitChange(); + }, []); + + const setShowEmpty = useCallback((show: boolean) => { + localStorage.setItem(SHOW_EMPTY_KEY, show ? 'true' : 'false'); emitChange(); }, []); @@ -42,5 +55,5 @@ export function useProjectSort() { setSortOrder(sortOrder === 'alpha' ? 'recent' : 'alpha'); }, [sortOrder, setSortOrder]); - return { sortOrder, setSortOrder, toggle }; + return { sortOrder, setSortOrder, showEmpty, setShowEmpty, toggle }; } diff --git a/apps/penpal/frontend/src/index.css b/apps/penpal/frontend/src/index.css index 52c9d78e5..33b94f785 100644 --- a/apps/penpal/frontend/src/index.css +++ b/apps/penpal/frontend/src/index.css @@ -466,6 +466,67 @@ a:hover { text-decoration: underline; } } .sidebar-home-header button:hover { color: var(--text-primary); } +/* E-PENPAL-VIEW-OPTIONS: view options floating panel */ +.view-options-wrap { position: relative; display: inline-flex; } +.view-options-btn { + display: flex; + align-items: center; + justify-content: center; +} +.view-options-btn svg { display: block; } +.view-options-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 180px; + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: 6px; + padding: 10px 12px; + box-shadow: var(--shadow-float); + z-index: 30; + display: flex; + flex-direction: column; + gap: 10px; +} +.view-options-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.75em; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.3px; +} +.view-options-label select { + font-size: 1.15em; + font-weight: 400; + text-transform: none; + letter-spacing: normal; + padding: 3px 6px; + border: 1px solid var(--border-subtle); + border-radius: 4px; + background: var(--bg-surface-secondary); + color: var(--text-primary); + cursor: pointer; +} +.view-options-toggle { + flex-direction: row; + align-items: center; + gap: 6px; + font-size: 0.8em; + font-weight: 400; + text-transform: none; + letter-spacing: normal; + color: var(--text-secondary); + cursor: pointer; +} +.view-options-toggle input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + /* ── Sidebar tree items ── */ .tree-item { display: flex; From 8366d2b3f767a13df79f45ee0b694604a7675f7a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 28 Mar 2026 23:15:35 -0400 Subject: [PATCH 2/2] refactor(penpal): break up view options requirements into focused pieces Split monolithic P-PENPAL-SORT and P-PENPAL-VIEW-OPTIONS into separate requirements for the panel, sort control, empty toggle, and persistence. Split E-PENPAL-HOME-SIDEBAR into sidebar structure and project items. Co-Authored-By: Claude Opus 4.6 --- apps/penpal/ERD.md | 19 ++++++++++++++----- apps/penpal/PRODUCT.md | 8 ++++++-- apps/penpal/TESTING.md | 5 ++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md index ca38fd1d1..cdcf78579 100644 --- a/apps/penpal/ERD.md +++ b/apps/penpal/ERD.md @@ -258,18 +258,27 @@ see-also: - **E-PENPAL-MD-RENDER**: Each rendered block is tagged with `data-source-line` (1-indexed). Heading IDs use the same slugification algorithm as Go's goldmark renderer. Mermaid blocks produce `.mermaid-container` divs with `data-mermaid-source`. ← [P-PENPAL-GFM](PRODUCT.md#P-PENPAL-GFM), [P-PENPAL-MERMAID](PRODUCT.md#P-PENPAL-MERMAID) -- **E-PENPAL-HOME-SIDEBAR**: In home mode (no active project), `Layout.tsx` renders a sidebar tree with: ⌂ header with view-options button and "+" add button, expandable workspace items (with child projects), standalone project items as top-level peers, and global "In Review" / "Recent" `NavLink`s in a global nav section. Visual dividers (`.home-section-divider`) separate the workspaces section, standalone projects section, and global navigation section; sections with no items are omitted along with their dividers. Workspace items show agent dots when any child project has `agentConnected`. Projects show: name (with branch as tooltip), agent dot, and expandable worktree children (when multiple worktrees exist). Non-main worktrees display a worktree icon; the main worktree does not. State: `expandedWorkspaces` and `expandedWorktreeProjects` `Set`. Clicking a project navigates to `/project/{qn}`. `useProjectSort` hook controls ordering; when `showEmpty` is false, projects with zero files are hidden; when shown, they sort last and are visually dimmed (`.deemphasized` class). - ← [P-PENPAL-HOME](PRODUCT.md#P-PENPAL-HOME), [P-PENPAL-HOME-TREE](PRODUCT.md#P-PENPAL-HOME-TREE), [P-PENPAL-HOME-PROJECT-ITEM](PRODUCT.md#P-PENPAL-HOME-PROJECT-ITEM), [P-PENPAL-HOME-NAVIGATE](PRODUCT.md#P-PENPAL-HOME-NAVIGATE) +- **E-PENPAL-HOME-SIDEBAR**: In home mode (no active project), `Layout.tsx` renders a sidebar tree with: ⌂ header with view-options button and "+" add button, expandable workspace items (with child projects), standalone project items as top-level peers, and global "In Review" / "Recent" `NavLink`s in a global nav section. Visual dividers (`.home-section-divider`) separate the workspaces section, standalone projects section, and global navigation section; sections with no items are omitted along with their dividers. + ← [P-PENPAL-HOME](PRODUCT.md#P-PENPAL-HOME), [P-PENPAL-HOME-TREE](PRODUCT.md#P-PENPAL-HOME-TREE) - **E-PENPAL-FE-HOME-LABEL**: When `isProjectMode` is false (home screen), the sidebar home header renders the text "Home" in a `` next to the ⌂ icon. The label inherits the header's font weight and uses `margin-right: auto` to push action buttons to the right side. ← [P-PENPAL-HOME-LABEL](PRODUCT.md#P-PENPAL-HOME-LABEL) -- **E-PENPAL-SORT**: `useProjectSort` hook uses `useSyncExternalStore` backed by localStorage keys `penpal-project-sort` (default `'alpha'`) and `penpal-show-empty` (default `'true'`). Subscribes to `storage` events for cross-tab sync. Home sidebar sorts by `localeCompare` for alpha or uses server order (sorted by `cache.ProjectsSortedByModTime()`) for recent. When `showEmpty` is false, projects with `fileCount === 0` are filtered out entirely. When shown, they sort last and get `.deemphasized` styling. - ← [P-PENPAL-SORT](PRODUCT.md#P-PENPAL-SORT) +- **E-PENPAL-HOME-PROJECT-ITEM**: Workspace items show agent dots when any child project has `agentConnected`. Projects show: name (with branch as tooltip), agent dot, and expandable worktree children (when multiple worktrees exist). Non-main worktrees display a worktree icon; the main worktree does not. State: `expandedWorkspaces` and `expandedWorktreeProjects` `Set`. Clicking a project navigates to `/project/{qn}`. + ← [P-PENPAL-HOME-PROJECT-ITEM](PRODUCT.md#P-PENPAL-HOME-PROJECT-ITEM), [P-PENPAL-HOME-NAVIGATE](PRODUCT.md#P-PENPAL-HOME-NAVIGATE) -- **E-PENPAL-VIEW-OPTIONS**: A `ViewOptionsPanel` renders as a floating panel (`.view-options-panel`) anchored below the view-options button in the sidebar home header. The panel contains a select dropdown for sort order (A->Z / Most Recent) and a checkbox toggle for show-empty-projects. The panel uses a ref and `mousedown` listener to dismiss on outside click. State changes update `useProjectSort` immediately without dismissing the panel. The view-options button uses the Filter Projects SVG icon from the Nexus icon library. +- **E-PENPAL-VIEW-OPTIONS**: The view-options button in the sidebar home header uses the Filter Projects SVG icon from the Nexus icon library. Clicking it toggles a `ViewOptionsPanel` rendered as a floating panel (`.view-options-panel`) anchored below the button. The panel uses a ref and `mousedown` listener to dismiss on outside click. State changes update `useProjectSort` immediately without dismissing the panel. ← [P-PENPAL-VIEW-OPTIONS](PRODUCT.md#P-PENPAL-VIEW-OPTIONS) +- **E-PENPAL-VIEW-OPTIONS-SORT**: The view options panel contains a `