diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d863..c3170642553 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454b..a1932feb258 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, FolderIcon, GitPullRequestIcon, PlusIcon, @@ -678,6 +679,7 @@ export default function Sidebar() { ); const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); const toggleProject = useUiStateStore((store) => store.toggleProject); + const setAllProjectsExpanded = useUiStateStore((store) => store.setAllProjectsExpanded); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( @@ -744,6 +746,11 @@ export default function Sidebar() { })), [orderedProjects, projectExpandedById], ); + const canCollapseAllProjects = useMemo( + () => + sidebarProjects.some((project) => project.expanded) || expandedThreadListsByProject.size > 0, + [expandedThreadListsByProject, sidebarProjects], + ); const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), @@ -1964,6 +1971,14 @@ export default function Sidebar() { }); }, []); + const handleCollapseAllProjects = useCallback(() => { + setExpandedThreadListsByProject((current) => (current.size === 0 ? current : new Set())); + setAllProjectsExpanded( + sidebarProjects.map((project) => project.id), + false, + ); + }, [setAllProjectsExpanded, sidebarProjects]); + const wordmark = (
@@ -2038,6 +2053,22 @@ export default function Sidebar() { Projects
+ + + } + > + + + Collapse all folders + { expect(next.projectOrder).toEqual([project1]); }); + it("setAllProjectsExpanded collapses every project without touching order", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const project2 = ProjectId.makeUnsafe("project-2"); + const initialState = makeUiState({ + projectExpandedById: { + [project1]: true, + [project2]: true, + }, + projectOrder: [project2, project1], + }); + + const next = setAllProjectsExpanded(initialState, [project1, project2], false); + + expect(next.projectExpandedById).toEqual({ + [project1]: false, + [project2]: false, + }); + expect(next.projectOrder).toEqual([project2, project1]); + }); + + it("setAllProjectsExpanded seeds missing project expansion state for known projects", () => { + const project1 = ProjectId.makeUnsafe("project-1"); + const project2 = ProjectId.makeUnsafe("project-2"); + const initialState = makeUiState({ + projectOrder: [project2, project1], + }); + + const next = setAllProjectsExpanded(initialState, [project1, project2], false); + + expect(next.projectExpandedById).toEqual({ + [project1]: false, + [project2]: false, + }); + expect(next.projectOrder).toEqual([project2, project1]); + }); + it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.makeUnsafe("thread-1"); const initialState = makeUiState({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 342f2db18f6..ac2cf11bcf5 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -356,6 +356,34 @@ export function setProjectExpanded( }; } +export function setAllProjectsExpanded( + state: UiState, + projectIds: readonly ProjectId[], + expanded: boolean, +): UiState { + const nextExpandedById = { ...state.projectExpandedById }; + let changed = false; + + for (const projectId of projectIds) { + if (nextExpandedById[projectId] !== expanded) { + nextExpandedById[projectId] = expanded; + changed = true; + } + } + + if (!changed) { + return state; + } + + if (recordsEqual(state.projectExpandedById, nextExpandedById)) { + return state; + } + return { + ...state, + projectExpandedById: nextExpandedById, + }; +} + export function reorderProjects( state: UiState, draggedProjectId: ProjectId, @@ -389,6 +417,7 @@ interface UiStateStore extends UiState { clearThreadUi: (threadId: ThreadId) => void; toggleProject: (projectId: ProjectId) => void; setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; + setAllProjectsExpanded: (projectIds: readonly ProjectId[], expanded: boolean) => void; reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; } @@ -404,6 +433,8 @@ export const useUiStateStore = create((set) => ({ toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), + setAllProjectsExpanded: (projectIds, expanded) => + set((state) => setAllProjectsExpanded(state, projectIds, expanded)), reorderProjects: (draggedProjectId, targetProjectId) => set((state) => reorderProjects(state, draggedProjectId, targetProjectId)), }));