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)),
}));