Skip to content
51 changes: 51 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ArchiveIcon,
ArrowUpDownIcon,
ChevronRightIcon,
ChevronsDownUpIcon,
CloudIcon,
FolderPlusIcon,
SearchIcon,
Expand Down Expand Up @@ -897,6 +898,7 @@ interface SidebarProjectItemProps {
suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
isManualProjectSorting: boolean;
dragHandleProps: SortableProjectHandleProps | null;
collapseAllProjects: () => void;
}

const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
Expand All @@ -917,6 +919,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
suppressProjectClickForContextMenuRef,
isManualProjectSorting,
dragHandleProps,
collapseAllProjects,
} = props;
const threadSortOrder = useSettings<SidebarThreadSortOrder>(
(settings) => settings.sidebarThreadSortOrder,
Expand Down Expand Up @@ -1214,13 +1217,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
event.stopPropagation();
return;
}
if (event.altKey) {
event.preventDefault();
event.stopPropagation();
collapseAllProjects();
return;
}
if (selectedThreadCount > 0) {
clearSelection();
}
toggleProject(project.projectKey);
},
[
clearSelection,
collapseAllProjects,
dragInProgressRef,
project.projectKey,
selectedThreadCount,
Expand Down Expand Up @@ -2486,6 +2496,8 @@ interface SidebarProjectsContentProps {
suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
projectsLength: number;
allProjectsCollapsed: boolean;
collapseAllProjects: () => void;
}

const SidebarProjectsContent = memo(function SidebarProjectsContent(
Expand Down Expand Up @@ -2526,6 +2538,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
suppressProjectClickForContextMenuRef,
attachProjectListAutoAnimateRef,
projectsLength,
allProjectsCollapsed,
collapseAllProjects,
} = props;

const handleProjectSortOrderChange = useCallback(
Expand Down Expand Up @@ -2609,6 +2623,23 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
onThreadSortOrderChange={handleThreadSortOrderChange}
onProjectGroupingModeChange={handleProjectGroupingModeChange}
/>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Collapse all projects"
data-testid="sidebar-collapse-all-trigger"
disabled={allProjectsCollapsed}
className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground/60"
onClick={collapseAllProjects}
/>
}
>
<ChevronsDownUpIcon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="right">Collapse all projects</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
Expand Down Expand Up @@ -2666,6 +2697,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
}
isManualProjectSorting={isManualProjectSorting}
dragHandleProps={dragHandleProps}
collapseAllProjects={collapseAllProjects}
/>
)}
</SortableProjectItem>
Expand Down Expand Up @@ -2696,6 +2728,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
isManualProjectSorting={isManualProjectSorting}
dragHandleProps={null}
collapseAllProjects={collapseAllProjects}
/>
))}
</SidebarMenu>
Expand All @@ -2717,6 +2750,7 @@ export default function Sidebar() {
const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
const projectOrder = useUiStateStore((store) => store.projectOrder);
const reorderProjects = useUiStateStore((store) => store.reorderProjects);
const collapseAllProjectsAction = useUiStateStore((store) => store.collapseAllProjects);
const navigate = useNavigate();
const pathname = useLocation({ select: (loc) => loc.pathname });
const isOnSettings = pathname.startsWith("/settings");
Expand Down Expand Up @@ -2998,6 +3032,21 @@ export default function Sidebar() {
visibleThreads,
]);
const isManualProjectSorting = sidebarProjectSortOrder === "manual";
const sortedProjectKeys = useMemo(
() => sortedProjects.map((project) => project.projectKey),
[sortedProjects],
);
const allProjectsCollapsed = useMemo(
() =>
sortedProjects.every(
(project) => (projectExpandedById[project.projectKey] ?? true) === false,
),
[projectExpandedById, sortedProjects],
);
const handleCollapseAllProjects = useCallback(
() => collapseAllProjectsAction(sortedProjectKeys),
[collapseAllProjectsAction, sortedProjectKeys],
);
const visibleSidebarThreadKeys = useMemo(
() =>
sortedProjects.flatMap((project) => {
Expand Down Expand Up @@ -3382,6 +3431,8 @@ export default function Sidebar() {
suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
projectsLength={projects.length}
allProjectsCollapsed={allProjectsCollapsed}
collapseAllProjects={handleCollapseAllProjects}
/>

<SidebarSeparator />
Expand Down
144 changes: 144 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {
clearThreadUi,
collapseAllProjects,
hydratePersistedProjectState,
markThreadVisited,
markThreadUnread,
Expand Down Expand Up @@ -397,6 +398,116 @@
expect(next.projectOrder).toEqual([project1]);
});

it("collapseAllProjects flips every known project to collapsed", () => {
const project1 = ProjectId.make("project-1");
const project2 = ProjectId.make("project-2");
const project3 = ProjectId.make("project-3");
const initialState = makeUiState({
projectExpandedById: {
[project1]: true,
[project2]: false,
[project3]: true,
},
projectOrder: [project1, project2, project3],
});

const next = collapseAllProjects(initialState, [project1, project2, project3]);

expect(next.projectExpandedById).toEqual({
[project1]: false,
[project2]: false,
[project3]: false,
});
expect(next.projectOrder).toEqual([project1, project2, project3]);
});

it("collapseAllProjects leaves thread state untouched", () => {
const project1 = ProjectId.make("project-1");
const thread1 = ThreadId.make("thread-1");
const initialState = makeUiState({
projectExpandedById: {
[project1]: true,
},
threadLastVisitedAtById: {
[thread1]: "2026-02-25T12:35:00.000Z",
},
threadChangedFilesExpandedById: {
[thread1]: {
"turn-1": false,
},
},
});

const next = collapseAllProjects(initialState, [project1]);

expect(next.threadLastVisitedAtById).toBe(initialState.threadLastVisitedAtById);
expect(next.threadChangedFilesExpandedById).toBe(initialState.threadChangedFilesExpandedById);
});

it("collapseAllProjects is a no-op when everything is already collapsed", () => {
const project1 = ProjectId.make("project-1");
const project2 = ProjectId.make("project-2");
const initialState = makeUiState({
projectExpandedById: {
[project1]: false,
[project2]: false,
},
});

const next = collapseAllProjects(initialState, [project1, project2]);

expect(next).toBe(initialState);
});

it("collapseAllProjects is a no-op when there are no known projects", () => {
const initialState = makeUiState();

const next = collapseAllProjects(initialState, []);

expect(next).toBe(initialState);
});

it("collapseAllProjects collapses untracked projects with implicit expanded state", () => {
const project1 = ProjectId.make("project-1");
const project2 = ProjectId.make("project-2");
const project3 = ProjectId.make("project-3");
const initialState = makeUiState({
projectExpandedById: {
[project1]: true,
},
});

const next = collapseAllProjects(initialState, [project1, project2, project3]);

expect(next.projectExpandedById).toEqual({
[project1]: false,
[project2]: false,
[project3]: false,
});
});

it("collapseAllProjects only writes the supplied logical keys, never physical ones", () => {
const logicalKey = ProjectId.make("logical-1");
const physicalKeyA = "physical-a";
const physicalKeyB = "physical-b";
const initialState = makeUiState({
projectExpandedById: {
[logicalKey]: true,
},
// projectOrder holds physical keys; collapseAllProjects must never mix
// them into projectExpandedById (which is keyed by logical key).
projectOrder: [physicalKeyA, physicalKeyB],
});

const next = collapseAllProjects(initialState, [logicalKey]);

expect(next.projectExpandedById).toEqual({
[logicalKey]: false,
});
expect(next.projectExpandedById).not.toHaveProperty(physicalKeyA);
expect(next.projectExpandedById).not.toHaveProperty(physicalKeyB);
});

it("clearThreadUi removes visit state for deleted threads", () => {
const thread1 = ThreadId.make("thread-1");
const initialState = makeUiState({
Expand Down Expand Up @@ -446,7 +557,7 @@
});

describe("uiStateStore persistence round-trip", () => {
function createLocalStorageStub(): Storage {

Check warning on line 560 in apps/web/src/uiStateStore.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-unicorn(consistent-function-scoping)

Function `createLocalStorageStub` does not capture any variables from its parent scope
const store = new Map<string, string>();
return {
clear: () => {
Expand Down Expand Up @@ -568,6 +679,39 @@
expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
});

it("collapseAllProjects writes every known cwd to collapsedProjectCwds on persist", () => {
const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" };
const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" };
const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" };

let state = syncProjects(makeUiState(), [projectA, projectB, projectC]);
state = setProjectExpanded(state, projectB.key, false);
state = collapseAllProjects(state, [
projectA.logicalKey,
projectB.logicalKey,
projectC.logicalKey,
]);
persistState(state);

const persisted = JSON.parse(
localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
) as PersistedUiState;

expect(persisted.collapsedProjectCwds?.toSorted()).toEqual(
[projectA.cwd, projectB.cwd, projectC.cwd].toSorted(),
);
expect(persisted.expandedProjectCwds ?? []).toEqual([]);

hydratePersistedProjectState(persisted);
const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]);

expect(rehydrated.projectExpandedById).toEqual({
[projectA.key]: false,
[projectB.key]: false,
[projectC.key]: false,
});
});

it("persists the default advertised endpoint preference", () => {
const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http");

Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,20 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded:
};
}

export function collapseAllProjects(state: UiState, projectIds: readonly string[]): UiState {
const nextExpandedById: Record<string, boolean> = {};
for (const projectId of Object.keys(state.projectExpandedById)) {
nextExpandedById[projectId] = false;
}
for (const projectId of projectIds) {
nextExpandedById[projectId] = false;
}
if (recordsEqual(state.projectExpandedById, nextExpandedById)) {
return state;
}
return { ...state, projectExpandedById: nextExpandedById };
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

export function reorderProjects(
state: UiState,
draggedProjectIds: readonly string[],
Expand Down Expand Up @@ -632,6 +646,7 @@ interface UiStateStore extends UiState {
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
toggleProject: (projectId: string) => void;
setProjectExpanded: (projectId: string, expanded: boolean) => void;
collapseAllProjects: (projectIds: readonly string[]) => void;
reorderProjects: (
draggedProjectIds: readonly string[],
targetProjectIds: readonly string[],
Expand All @@ -654,6 +669,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
setProjectExpanded: (projectId, expanded) =>
set((state) => setProjectExpanded(state, projectId, expanded)),
collapseAllProjects: (projectIds) => set((state) => collapseAllProjects(state, projectIds)),
reorderProjects: (draggedProjectIds, targetProjectIds) =>
set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)),
}));
Expand Down
Loading