From 275fb9168501b7e0b6e27fa0351f41419c6d116f Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Thu, 23 Apr 2026 14:11:39 +0200 Subject: [PATCH 1/6] feat(web): add collapse-all button to projects sidebar Closes #2306. Option/Alt+click on a project chevron triggers the same collapse-all behavior as the new header button. --- apps/web/src/components/Sidebar.tsx | 44 +++++++++++-- apps/web/src/uiStateStore.test.ts | 99 +++++++++++++++++++++++++++++ apps/web/src/uiStateStore.ts | 13 ++++ 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5d778eec36e..969032f3ba1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, CloudIcon, GitPullRequestIcon, PlusIcon, @@ -933,6 +934,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); + const collapseAllProjects = useUiStateStore((state) => state.collapseAllProjects); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -1236,6 +1238,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [dragInProgressRef, project.projectKey, toggleProject], ); + const handleProjectChevronClickCapture = useCallback( + (event: React.MouseEvent) => { + if (!event.altKey) { + return; + } + event.preventDefault(); + event.stopPropagation(); + collapseAllProjects(); + }, + [collapseAllProjects], + ); + const handleProjectButtonPointerDownCapture = useCallback( (event: React.PointerEvent) => { suppressProjectClickForContextMenuRef.current = false; @@ -1964,6 +1978,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec aria-hidden="true" title={projectStatus.label} className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} + onClickCapture={handleProjectChevronClickCapture} > ) : ( - + + + )} @@ -2519,6 +2536,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const collapseAllProjects = useUiStateStore((state) => state.collapseAllProjects); return ( @@ -2582,6 +2600,22 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( onThreadSortOrderChange={handleThreadSortOrderChange} onProjectGroupingModeChange={handleProjectGroupingModeChange} /> + + + } + > + + + Collapse all projects + { 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); + + 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); + + 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); + + 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("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ @@ -531,6 +601,35 @@ describe("uiStateStore persistence round-trip", () => { 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); + 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("preserves expand state across restart when project's logical key changes", () => { // After restart, in-memory previousExpandedById is empty, so the // previousLogicalKey-to-state bridge in syncProjects cannot help. The diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 8bd65ffc56a..7f667392bf6 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -556,6 +556,17 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded: }; } +export function collapseAllProjects(state: UiState): UiState { + const nextExpandedById: Record = {}; + for (const projectId of Object.keys(state.projectExpandedById)) { + nextExpandedById[projectId] = false; + } + if (recordsEqual(state.projectExpandedById, nextExpandedById)) { + return state; + } + return { ...state, projectExpandedById: nextExpandedById }; +} + export function reorderProjects( state: UiState, draggedProjectIds: readonly string[], @@ -608,6 +619,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; + collapseAllProjects: () => void; reorderProjects: ( draggedProjectIds: readonly string[], targetProjectIds: readonly string[], @@ -628,6 +640,7 @@ export const useUiStateStore = create((set) => ({ toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), + collapseAllProjects: () => set((state) => collapseAllProjects(state)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), })); From 31b8225f95676254bf2ae1a01fb638e131a852ef Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Thu, 23 Apr 2026 14:18:22 +0200 Subject: [PATCH 2/6] refactor(web): widen alt+click collapse-all target to full project row Alt+click anywhere on a project row now triggers collapse-all, not just the chevron. Matches what users expect when the whole row is the click target for toggling. --- apps/web/src/components/Sidebar.tsx | 32 +++++++++++------------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 969032f3ba1..72bd09fcf24 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1210,6 +1210,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec event.stopPropagation(); return; } + if (event.altKey) { + event.preventDefault(); + event.stopPropagation(); + collapseAllProjects(); + return; + } if (selectedThreadCount > 0) { clearSelection(); } @@ -1217,6 +1223,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, [ clearSelection, + collapseAllProjects, dragInProgressRef, project.projectKey, selectedThreadCount, @@ -1238,18 +1245,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [dragInProgressRef, project.projectKey, toggleProject], ); - const handleProjectChevronClickCapture = useCallback( - (event: React.MouseEvent) => { - if (!event.altKey) { - return; - } - event.preventDefault(); - event.stopPropagation(); - collapseAllProjects(); - }, - [collapseAllProjects], - ); - const handleProjectButtonPointerDownCapture = useCallback( (event: React.PointerEvent) => { suppressProjectClickForContextMenuRef.current = false; @@ -1978,7 +1973,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec aria-hidden="true" title={projectStatus.label} className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`} - onClickCapture={handleProjectChevronClickCapture} > ) : ( - - - + )} From eda2e611a7867740dcab728c15784f50da90516d Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Thu, 23 Apr 2026 15:52:31 +0200 Subject: [PATCH 3/6] refactor(web): lift collapse-all state to top-level Sidebar --- apps/web/src/components/Sidebar.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 72bd09fcf24..c17b70fc2ff 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2468,6 +2468,8 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + allProjectsCollapsed: boolean; + collapseAllProjects: () => void; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2508,6 +2510,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, + allProjectsCollapsed, + collapseAllProjects, } = props; const handleProjectSortOrderChange = useCallback( @@ -2528,7 +2532,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); - const collapseAllProjects = useUiStateStore((state) => state.collapseAllProjects); return ( @@ -2599,7 +2602,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( type="button" aria-label="Collapse all projects" data-testid="sidebar-collapse-all-trigger" - 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={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} /> } @@ -2716,6 +2720,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"); @@ -2993,6 +2998,13 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const allProjectsCollapsed = useMemo( + () => + sortedProjects.every( + (project) => (projectExpandedById[project.projectKey] ?? true) === false, + ), + [projectExpandedById, sortedProjects], + ); const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { @@ -3377,6 +3389,8 @@ export default function Sidebar() { suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} projectsLength={projects.length} + allProjectsCollapsed={allProjectsCollapsed} + collapseAllProjects={collapseAllProjectsAction} /> From 9531315c5c403e14216e8361fc3e77714918d4a4 Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Fri, 1 May 2026 11:18:09 +0200 Subject: [PATCH 4/6] fix: collapse all projects now includes untracked projects Fixes issue where collapseAllProjects only collapsed projects already in projectExpandedById, missing projects with implicit expanded state. Now iterates over both projectOrder and existing tracked projects to ensure all projects are collapsed. --- apps/web/src/uiStateStore.test.ts | 20 ++++++++++++++++++++ apps/web/src/uiStateStore.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 6fedfe15446..7948f83de3c 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -453,6 +453,26 @@ describe("uiStateStore pure functions", () => { 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, + }, + projectOrder: [project1, project2, project3], + }); + + const next = collapseAllProjects(initialState); + + expect(next.projectExpandedById).toEqual({ + [project1]: false, + [project2]: false, + [project3]: false, + }); + }); + it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 7f667392bf6..822dffd4ba3 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -558,7 +558,11 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded: export function collapseAllProjects(state: UiState): UiState { const nextExpandedById: Record = {}; - for (const projectId of Object.keys(state.projectExpandedById)) { + const allProjectIds = new Set([ + ...state.projectOrder, + ...Object.keys(state.projectExpandedById), + ]); + for (const projectId of allProjectIds) { nextExpandedById[projectId] = false; } if (recordsEqual(state.projectExpandedById, nextExpandedById)) { From 22a3ad6184ea4219d8194257294cfda2835994ae Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Fri, 1 May 2026 11:24:15 +0200 Subject: [PATCH 5/6] chore: fix formatting in uiStateStore.ts --- apps/web/src/uiStateStore.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 822dffd4ba3..4b185b35833 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -558,10 +558,7 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded: export function collapseAllProjects(state: UiState): UiState { const nextExpandedById: Record = {}; - const allProjectIds = new Set([ - ...state.projectOrder, - ...Object.keys(state.projectExpandedById), - ]); + const allProjectIds = new Set([...state.projectOrder, ...Object.keys(state.projectExpandedById)]); for (const projectId of allProjectIds) { nextExpandedById[projectId] = false; } From a15b2525f0d2a87090f690ba1a209d0e214712f9 Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Fri, 1 May 2026 11:40:45 +0200 Subject: [PATCH 6/6] refactor: pass logical project keys to collapseAllProjects Previously collapseAllProjects unioned state.projectOrder (physical keys) with Object.keys(state.projectExpandedById) (logical keys), polluting projectExpandedById with physical-key entries that the UI never reads when grouping is active. The recordsEqual no-op also broke in that scenario, causing unnecessary re-renders after each syncProjects. Make the contract explicit: callers pass the logical project IDs they want collapsed. Sidebar derives them from sortedProjects (memoized) and wraps the action so both the header button and Alt+click on a project trigger the same behavior. Adds a regression test covering the physical/logical key mix-up. --- apps/web/src/components/Sidebar.tsx | 15 +++++++++-- apps/web/src/uiStateStore.test.ts | 39 +++++++++++++++++++++++------ apps/web/src/uiStateStore.ts | 12 +++++---- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7c50c1c90d3..7f6bb97bc95 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -898,6 +898,7 @@ interface SidebarProjectItemProps { suppressProjectClickForContextMenuRef: React.RefObject; isManualProjectSorting: boolean; dragHandleProps: SortableProjectHandleProps | null; + collapseAllProjects: () => void; } const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { @@ -918,6 +919,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, + collapseAllProjects, } = props; const threadSortOrder = useSettings( (settings) => settings.sidebarThreadSortOrder, @@ -940,7 +942,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); - const collapseAllProjects = useUiStateStore((state) => state.collapseAllProjects); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -2696,6 +2697,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( } isManualProjectSorting={isManualProjectSorting} dragHandleProps={dragHandleProps} + collapseAllProjects={collapseAllProjects} /> )} @@ -2726,6 +2728,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} isManualProjectSorting={isManualProjectSorting} dragHandleProps={null} + collapseAllProjects={collapseAllProjects} /> ))} @@ -3029,6 +3032,10 @@ export default function Sidebar() { visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const sortedProjectKeys = useMemo( + () => sortedProjects.map((project) => project.projectKey), + [sortedProjects], + ); const allProjectsCollapsed = useMemo( () => sortedProjects.every( @@ -3036,6 +3043,10 @@ export default function Sidebar() { ), [projectExpandedById, sortedProjects], ); + const handleCollapseAllProjects = useCallback( + () => collapseAllProjectsAction(sortedProjectKeys), + [collapseAllProjectsAction, sortedProjectKeys], + ); const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { @@ -3421,7 +3432,7 @@ export default function Sidebar() { attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} projectsLength={projects.length} allProjectsCollapsed={allProjectsCollapsed} - collapseAllProjects={collapseAllProjectsAction} + collapseAllProjects={handleCollapseAllProjects} /> diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 7948f83de3c..101127080aa 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -397,7 +397,7 @@ describe("uiStateStore pure functions", () => { projectOrder: [project1, project2, project3], }); - const next = collapseAllProjects(initialState); + const next = collapseAllProjects(initialState, [project1, project2, project3]); expect(next.projectExpandedById).toEqual({ [project1]: false, @@ -424,7 +424,7 @@ describe("uiStateStore pure functions", () => { }, }); - const next = collapseAllProjects(initialState); + const next = collapseAllProjects(initialState, [project1]); expect(next.threadLastVisitedAtById).toBe(initialState.threadLastVisitedAtById); expect(next.threadChangedFilesExpandedById).toBe(initialState.threadChangedFilesExpandedById); @@ -440,7 +440,7 @@ describe("uiStateStore pure functions", () => { }, }); - const next = collapseAllProjects(initialState); + const next = collapseAllProjects(initialState, [project1, project2]); expect(next).toBe(initialState); }); @@ -448,7 +448,7 @@ describe("uiStateStore pure functions", () => { it("collapseAllProjects is a no-op when there are no known projects", () => { const initialState = makeUiState(); - const next = collapseAllProjects(initialState); + const next = collapseAllProjects(initialState, []); expect(next).toBe(initialState); }); @@ -461,10 +461,9 @@ describe("uiStateStore pure functions", () => { projectExpandedById: { [project1]: true, }, - projectOrder: [project1, project2, project3], }); - const next = collapseAllProjects(initialState); + const next = collapseAllProjects(initialState, [project1, project2, project3]); expect(next.projectExpandedById).toEqual({ [project1]: false, @@ -473,6 +472,28 @@ describe("uiStateStore pure functions", () => { }); }); + 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({ @@ -651,7 +672,11 @@ describe("uiStateStore persistence round-trip", () => { let state = syncProjects(makeUiState(), [projectA, projectB, projectC]); state = setProjectExpanded(state, projectB.key, false); - state = collapseAllProjects(state); + state = collapseAllProjects(state, [ + projectA.logicalKey, + projectB.logicalKey, + projectC.logicalKey, + ]); persistState(state); const persisted = JSON.parse( diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4b185b35833..50e335f0a78 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -556,10 +556,12 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded: }; } -export function collapseAllProjects(state: UiState): UiState { +export function collapseAllProjects(state: UiState, projectIds: readonly string[]): UiState { const nextExpandedById: Record = {}; - const allProjectIds = new Set([...state.projectOrder, ...Object.keys(state.projectExpandedById)]); - for (const projectId of allProjectIds) { + for (const projectId of Object.keys(state.projectExpandedById)) { + nextExpandedById[projectId] = false; + } + for (const projectId of projectIds) { nextExpandedById[projectId] = false; } if (recordsEqual(state.projectExpandedById, nextExpandedById)) { @@ -620,7 +622,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; - collapseAllProjects: () => void; + collapseAllProjects: (projectIds: readonly string[]) => void; reorderProjects: ( draggedProjectIds: readonly string[], targetProjectIds: readonly string[], @@ -641,7 +643,7 @@ export const useUiStateStore = create((set) => ({ toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), - collapseAllProjects: () => set((state) => collapseAllProjects(state)), + collapseAllProjects: (projectIds) => set((state) => collapseAllProjects(state, projectIds)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), }));