Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/penpal/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,25 @@ see-also:
- <a id="E-PENPAL-MD-RENDER"></a>**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)

- <a id="E-PENPAL-HOME-SIDEBAR"></a>**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<string>`. Clicking a project navigates to `/project/{qn}`. `useProjectSort` hook controls ordering; projects with zero files 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)
- <a id="E-PENPAL-HOME-SIDEBAR"></a>**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)

- <a id="E-PENPAL-FE-HOME-LABEL"></a>**E-PENPAL-FE-HOME-LABEL**: When `isProjectMode` is false (home screen), the sidebar home header renders the text "Home" in a `<span class="home-label">` 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)

- <a id="E-PENPAL-SORT"></a>**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.
- <a id="E-PENPAL-HOME-PROJECT-ITEM"></a>**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<string>`. 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)

- <a id="E-PENPAL-VIEW-OPTIONS"></a>**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)

- <a id="E-PENPAL-VIEW-OPTIONS-SORT"></a>**E-PENPAL-VIEW-OPTIONS-SORT**: The view options panel contains a `<select>` dropdown for sort order with options A->Z and Most Recent. Selection updates the `sortOrder` value in `useProjectSort`. Home sidebar sorts by `localeCompare` for alpha or uses server order (sorted by `cache.ProjectsSortedByModTime()`) for recent.
← [P-PENPAL-VIEW-OPTIONS-SORT](PRODUCT.md#P-PENPAL-VIEW-OPTIONS-SORT)

- <a id="E-PENPAL-VIEW-OPTIONS-EMPTY"></a>**E-PENPAL-VIEW-OPTIONS-EMPTY**: The view options panel contains a checkbox toggle for show-empty-projects. When `showEmpty` is false, projects with `fileCount === 0` are filtered out entirely and workspaces with no visible projects are hidden. When shown, empty projects sort last and get `.deemphasized` styling.
← [P-PENPAL-VIEW-OPTIONS-EMPTY](PRODUCT.md#P-PENPAL-VIEW-OPTIONS-EMPTY)

- <a id="E-PENPAL-SORT"></a>**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.
← [P-PENPAL-SORT](PRODUCT.md#P-PENPAL-SORT)

- <a id="E-PENPAL-HOME-DEFAULT"></a>**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 `/`.
Expand Down
8 changes: 7 additions & 1 deletion apps/penpal/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ The home view is the top-level navigation state. It shows all workspaces and sta

- <a id="P-PENPAL-HOME-NAVIGATE"></a>**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.

- <a id="P-PENPAL-SORT"></a>**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.
- <a id="P-PENPAL-VIEW-OPTIONS"></a>**P-PENPAL-VIEW-OPTIONS**: The home sidebar header includes a view options button (filter icon) just to the left of the "+" button. Clicking it opens a floating panel. The panel stays open while the user changes settings and dismisses only when clicking outside it (including clicking the view options button again).

- <a id="P-PENPAL-VIEW-OPTIONS-SORT"></a>**P-PENPAL-VIEW-OPTIONS-SORT**: The view options panel contains a "Project order" dropdown with two options: A->Z (default) and Most Recent. Selecting an option reorders the project list immediately.

- <a id="P-PENPAL-VIEW-OPTIONS-EMPTY"></a>**P-PENPAL-VIEW-OPTIONS-EMPTY**: The view options panel contains a "Show empty projects" toggle that defaults to on. When off, projects with zero recognized files are hidden entirely. When on, they appear sorted last and visually dimmed.

- <a id="P-PENPAL-SORT"></a>**P-PENPAL-SORT**: The selected sort order and show-empty preference persist in localStorage and sync across tabs/windows via storage events.

- <a id="P-PENPAL-HOME-DEFAULT"></a>**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.

Expand Down
4 changes: 4 additions & 0 deletions apps/penpal/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ 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 Panel (P-PENPAL-VIEW-OPTIONS) | — | Layout.test.tsx | — | — |
| View Options — Sort (P-PENPAL-VIEW-OPTIONS-SORT) | — | useProjectSort.test.ts, Layout.test.tsx | — | — |
| View Options — Empty Projects (P-PENPAL-VIEW-OPTIONS-EMPTY) | — | useProjectSort.test.ts, Layout.test.tsx | — | — |
| Sort Persistence (P-PENPAL-SORT) | — | useProjectSort.test.ts | — | — |

## Known Coverage Gaps

Expand Down
133 changes: 133 additions & 0 deletions apps/penpal/frontend/src/components/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ describe('Layout', () => {
link.remove();
});


// E-PENPAL-SIDEBAR-RESIZE: sidebar resize tests
describe('sidebar resize', () => {
it('renders the sidebar resize handle', () => {
Expand Down Expand Up @@ -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(
<MemoryRouter>
<Layout />
</MemoryRouter>,
);

expect(screen.getByLabelText('View options')).toBeInTheDocument();
});

it('opens view options panel on click', async () => {
render(
<MemoryRouter>
<Layout />
</MemoryRouter>,
);

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(
<MemoryRouter>
<Layout />
</MemoryRouter>,
);

// 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(
<MemoryRouter>
<Layout />
</MemoryRouter>,
);

// 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();
});
});
89 changes: 76 additions & 13 deletions apps/penpal/frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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(
Expand All @@ -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));
Expand All @@ -340,10 +360,15 @@ export default function Layout() {
const sortedWorkspaceProjects = useMemo(() => {
const map = new Map<string, APIProject[]>();
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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -1088,21 +1113,59 @@ 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 */}
<div className="sidebar-home-header">
<span className="home-icon">⌂</span>
<span className="home-label">Home</span>
<button
title={`Sort: ${sortOrder === 'alpha' ? 'A→Z' : 'Recent'}`}
onClick={toggleSort}
>{sortOrder === 'alpha' ? 'A→Z' : '⏱'}</button>
<div className="view-options-wrap" ref={viewOptionsPanelRef}>
<button
className="view-options-btn"
title="View options"
aria-label="View options"
aria-haspopup="true"
aria-expanded={showViewOptions}
onClick={() => setShowViewOptions((prev) => !prev)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14" aria-hidden="true">
<line x1="4" y1="6" x2="20" y2="6" />
<circle cx="9" cy="6" r="2" />
<line x1="4" y1="12" x2="20" y2="12" />
<circle cx="15" cy="12" r="2" />
<line x1="4" y1="18" x2="20" y2="18" />
<circle cx="12" cy="18" r="2" />
</svg>
</button>
{showViewOptions && (
<div className="view-options-panel">
<label className="view-options-label">
Project order
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'alpha' | 'recent')}
>
<option value="alpha">A→Z</option>
<option value="recent">Most Recent</option>
</select>
</label>
<label className="view-options-label view-options-toggle">
<input
type="checkbox"
checked={showEmpty}
onChange={(e) => setShowEmpty(e.target.checked)}
/>
Show empty projects
</label>
</div>
)}
</div>
<button
title="Add workspace or project"
onClick={() => { setShowAddModal(true); setAddPath(''); setAddError(''); }}
>+</button>
</div>

{/* Workspaces */}
{workspaces.map(ws => {
{visibleWorkspaces.map(ws => {
const isExpanded = expandedWorkspaces.has(ws);
const wsProjects = sortedWorkspaceProjects.get(ws) || [];
const hasAgent = wsProjects.some(p => p.agentConnected);
Expand Down Expand Up @@ -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 && (
<div className="home-section-divider" />
)}

Expand All @@ -1197,7 +1260,7 @@ export default function Layout() {
))}

{/* Divider before global nav */}
{(workspaces.length > 0 || standaloneProjects.length > 0) && (
{(visibleWorkspaces.length > 0 || standaloneProjects.length > 0) && (
<div className="home-section-divider" />
)}

Expand Down
Loading