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
25 changes: 25 additions & 0 deletions openspec/changes/add-collapsible-projects-section/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Add Collapsible Projects Section

## Why

The sidebar **Projects** section is always expanded and always occupies a
capped slice of vertical space (`40vh`, or `min(24vh, 180px)` when dense),
becoming its own scroll area when there are many projects. Users who keep many
projects but spend most of their time in the tasks list pay that fixed space
even when they are not switching projects.

## What changes

- Add a collapse/expand toggle to the **Projects** section header: clicking the
header (or its chevron) hides the project list entirely and reclaims the
space for the tasks section.
- Show a chevron on the header that reflects collapsed vs. expanded state.
- Persist the collapsed state via the existing settings persistence so it
survives app restarts.

## Impact

- New capability `sidebar`.
- Updates `src/components/Sidebar.tsx`; adds a persisted `projectsCollapsed`
flag to the store (`types`, `core`, `autosave`, `persistence`, `ui`).
- No backend or IPC change — the flag rides the existing persisted-state file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Sidebar Specification

## ADDED Requirements

### Requirement: Projects section can be collapsed

The sidebar Projects section SHALL be collapsible via a toggle on its header.
When collapsed, the project list is hidden entirely and its vertical space is
reclaimed by the tasks section. The collapsed state SHALL persist across app
restarts.

#### Scenario: User collapses the Projects section

- **WHEN** the Projects section is expanded and the user activates the section
header toggle
- **THEN** the project list is hidden
- **AND** the freed vertical space is given to the tasks section
- **AND** the header chevron indicates the collapsed state

#### Scenario: User expands the Projects section

- **WHEN** the Projects section is collapsed and the user activates the section
header toggle
- **THEN** the project list is shown again
- **AND** the header chevron indicates the expanded state

#### Scenario: Collapsed state survives a restart

- **WHEN** the user has collapsed the Projects section
- **AND** the app is restarted
- **THEN** the Projects section is still collapsed

#### Scenario: Add-project control stays reachable while collapsed

- **WHEN** the Projects section is collapsed
- **THEN** the add-project control on the section header remains visible and
usable without first expanding the section

#### Scenario: Arrow-key navigation skips hidden projects

- **WHEN** the sidebar is focused
- **AND** the Projects section is collapsed
- **THEN** pressing `↑` or `↓` does not move focus into the hidden project
list — navigation stays within the visible task list
16 changes: 16 additions & 0 deletions openspec/changes/add-collapsible-projects-section/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Tasks — Add Collapsible Projects Section

- [x] Add a persisted `projectsCollapsed` flag to the store (`types`, `core`,
`autosave`, `persistence`) defaulting to expanded.
- [x] Add a `setProjectsCollapsed(boolean)` helper in `store/ui` that also
drops `sidebarFocusedProjectId` when collapsing, and export it from the
store barrel.
- [x] Make the Projects section header a toggle (chevron + label) that
animates the list collapse smoothly via a CSS grid-rows transition, with
hover and focus-visible styles on the toggle button.
- [x] Skip "project mode" in sidebar arrow-key navigation while collapsed so
`↑/↓` does not walk through invisible items.
- [x] Cover the persisted flag in the persistence test suite (including
rejection of non-boolean values).
- [x] Validate with `npm run typecheck`, `npm test`, and
`openspec validate --all --strict`.
247 changes: 146 additions & 101 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getPanelUserSize,
setPanelUserSize,
toggleSettingsDialog,
setProjectsCollapsed,
uncollapseTask,
isProjectMissing,
showNotification,
Expand Down Expand Up @@ -439,16 +440,51 @@ export function Sidebar() {
padding: '0 2px',
}}
>
<label
<button
type="button"
class="projects-toggle"
onClick={() => setProjectsCollapsed(!store.projectsCollapsed)}
aria-expanded={!store.projectsCollapsed}
aria-controls="sidebar-projects-list"
title={store.projectsCollapsed ? 'Expand projects' : 'Collapse projects'}
style={{
'font-size': sf(12),
display: 'flex',
'align-items': 'center',
gap: '4px',
flex: '1',
'min-width': '0',
background: 'transparent',
border: 'none',
padding: '2px 4px',
margin: '0',
cursor: 'pointer',
color: theme.fgMuted,
'text-transform': 'uppercase',
'letter-spacing': '0.05em',
}}
>
Projects
</label>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
style={{
'flex-shrink': '0',
transform: store.projectsCollapsed ? 'rotate(-90deg)' : 'none',
transition: 'transform 0.15s ease',
}}
>
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
</svg>
<span
style={{
'font-size': sf(12),
'text-transform': 'uppercase',
'letter-spacing': '0.05em',
}}
>
Projects
</span>
</button>
<IconButton
icon={
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
Expand All @@ -461,110 +497,119 @@ export function Sidebar() {
/>
</div>

{/* Scrollable project list */}
{/* Scrollable project list — outer grid-rows wrapper animates the
collapse smoothly without needing a measured height. */}
<div
style={{
display: 'flex',
'flex-direction': 'column',
gap: '6px',
flex: '0 1 auto',
'min-height': '0',
'max-height': projectListMaxHeight(),
'overflow-y': 'auto',
}}
class="projects-collapser"
classList={{ 'is-collapsed': store.projectsCollapsed }}
aria-hidden={store.projectsCollapsed}
>
<For each={store.projects}>
{(project) => (
<div
role="button"
tabIndex={0}
data-project-id={project.id}
onClick={() => setEditingProject(project)}
onKeyDown={(e) => {
if (e.key === 'Enter') setEditingProject(project);
}}
style={{
display: 'flex',
'align-items': 'center',
gap: '6px',
padding: '4px 6px',
'border-radius': '6px',
background: isProjectMissing(project.id)
? `color-mix(in srgb, ${theme.warning} 8%, ${theme.bgInput})`
: theme.bgInput,
'font-size': sf(12),
cursor: 'pointer',
border:
store.sidebarFocused && store.sidebarFocusedProjectId === project.id
? `1.5px solid var(--border-focus)`
: '1.5px solid transparent',
'flex-shrink': '0',
}}
>
<div
style={{
width: '8px',
height: '8px',
'border-radius': '50%',
background: project.color,
'flex-shrink': '0',
}}
/>
<div style={{ flex: '1', 'min-width': '0', overflow: 'hidden' }}>
<div class="projects-clip">
<div
id="sidebar-projects-list"
style={{
display: 'flex',
'flex-direction': 'column',
gap: '6px',
'min-height': '0',
'max-height': projectListMaxHeight(),
'overflow-y': 'auto',
}}
>
<For each={store.projects}>
{(project) => (
<div
style={{
color: theme.fg,
'font-weight': '500',
'white-space': 'nowrap',
overflow: 'hidden',
'text-overflow': 'ellipsis',
role="button"
tabIndex={0}
data-project-id={project.id}
onClick={() => setEditingProject(project)}
onKeyDown={(e) => {
if (e.key === 'Enter') setEditingProject(project);
}}
>
{project.name}
</div>
<div
style={{
color: isProjectMissing(project.id) ? theme.warning : theme.fgSubtle,
'font-size': sf(11),
'white-space': 'nowrap',
overflow: 'hidden',
'text-overflow': 'ellipsis',
display: 'flex',
'align-items': 'center',
gap: '6px',
padding: '4px 6px',
'border-radius': '6px',
background: isProjectMissing(project.id)
? `color-mix(in srgb, ${theme.warning} 8%, ${theme.bgInput})`
: theme.bgInput,
'font-size': sf(12),
cursor: 'pointer',
border:
store.sidebarFocused && store.sidebarFocusedProjectId === project.id
? `1.5px solid var(--border-focus)`
: '1.5px solid transparent',
'flex-shrink': '0',
}}
>
{isProjectMissing(project.id)
? 'Folder not found'
: abbreviatePath(project.path)}
<div
style={{
width: '8px',
height: '8px',
'border-radius': '50%',
background: project.color,
'flex-shrink': '0',
}}
/>
<div style={{ flex: '1', 'min-width': '0', overflow: 'hidden' }}>
<div
style={{
color: theme.fg,
'font-weight': '500',
'white-space': 'nowrap',
overflow: 'hidden',
'text-overflow': 'ellipsis',
}}
>
{project.name}
</div>
<div
style={{
color: isProjectMissing(project.id) ? theme.warning : theme.fgSubtle,
'font-size': sf(11),
'white-space': 'nowrap',
overflow: 'hidden',
'text-overflow': 'ellipsis',
}}
>
{isProjectMissing(project.id)
? 'Folder not found'
: abbreviatePath(project.path)}
</div>
</div>
<button
class="icon-btn"
onClick={(e) => {
e.stopPropagation();
setConfirmRemove(project.id);
}}
title="Remove project"
style={{
background: 'transparent',
border: 'none',
color: theme.fgSubtle,
cursor: 'pointer',
'font-size': sf(13),
'line-height': '1',
padding: '0 2px',
'flex-shrink': '0',
}}
>
&times;
</button>
</div>
</div>
<button
class="icon-btn"
onClick={(e) => {
e.stopPropagation();
setConfirmRemove(project.id);
}}
title="Remove project"
style={{
background: 'transparent',
border: 'none',
color: theme.fgSubtle,
cursor: 'pointer',
'font-size': sf(13),
'line-height': '1',
padding: '0 2px',
'flex-shrink': '0',
}}
>
&times;
</button>
</div>
)}
</For>
)}
</For>

<Show when={store.projects.length === 0}>
<span style={{ 'font-size': sf(11), color: theme.fgSubtle, padding: '0 2px' }}>
No projects linked yet.
</span>
</Show>
<Show when={store.projects.length === 0}>
<span style={{ 'font-size': sf(11), color: theme.fgSubtle, padding: '0 2px' }}>
No projects linked yet.
</span>
</Show>
</div>
</div>
</div>
</div>

Expand Down
1 change: 1 addition & 0 deletions src/store/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function persistedSnapshot(): string {
showSteps: store.showSteps,
showSidebarTips: store.showSidebarTips,
showSidebarProgress: store.showSidebarProgress,
projectsCollapsed: store.projectsCollapsed,
desktopNotificationsEnabled: store.desktopNotificationsEnabled,
inactiveColumnOpacity: store.inactiveColumnOpacity,
editorCommand: store.editorCommand,
Expand Down
1 change: 1 addition & 0 deletions src/store/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const [store, setStore] = createStore<AppStore>({
showSteps: false,
showSidebarTips: true,
showSidebarProgress: true,
projectsCollapsed: false,
desktopNotificationsEnabled: false,
inactiveColumnOpacity: 0.6,
editorCommand: '',
Expand Down
Loading
Loading