From cb8887c34ebf583fd01ca7321ff6573af8191dd1 Mon Sep 17 00:00:00 2001 From: Alec Ramos <60238535+alecramos-sudo@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:15:30 -0700 Subject: [PATCH 1/2] feat: add setting to control default project expansion on startup (#1910) Projects now default to collapsed in the sidebar on restart. A new "Expand projects on startup" toggle in Settings > General lets users restore the previous always-expanded behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/clientPersistence.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 16 +++++--- .../components/settings/SettingsPanels.tsx | 33 +++++++++++++++++ apps/web/src/environments/runtime/service.ts | 3 ++ apps/web/src/hooks/useSettings.ts | 2 +- apps/web/src/localApi.test.ts | 5 +++ apps/web/src/uiStateStore.ts | 37 ++++++++++++------- packages/contracts/src/settings.ts | 3 ++ 8 files changed, 81 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index df2178c0b0..ddaebee450 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectsDefaultExpanded: false, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c5725c6d0d..b7114038ff 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -977,6 +977,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); + const sidebarProjectsDefaultExpanded = useSettings( + (settings) => settings.sidebarProjectsDefaultExpanded, + ); const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); @@ -1102,7 +1105,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec // already fetched into allSidebarThreads, so we can use them directly. const projectThreads = allSidebarThreads; const projectExpanded = useUiStateStore( - (state) => state.projectExpandedById[project.projectKey] ?? true, + (state) => state.projectExpandedById[project.projectKey] ?? sidebarProjectsDefaultExpanded, ); const threadLastVisitedAts = useUiStateStore( useShallow((state) => @@ -1251,13 +1254,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (selectedThreadCount > 0) { clearSelection(); } - toggleProject(project.projectKey); + toggleProject(project.projectKey, sidebarProjectsDefaultExpanded); }, [ clearSelection, dragInProgressRef, project.projectKey, selectedThreadCount, + sidebarProjectsDefaultExpanded, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, toggleProject, @@ -1271,9 +1275,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - toggleProject(project.projectKey); + toggleProject(project.projectKey, sidebarProjectsDefaultExpanded); }, - [dragInProgressRef, project.projectKey, toggleProject], + [dragInProgressRef, project.projectKey, sidebarProjectsDefaultExpanded, toggleProject], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -2336,6 +2340,7 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const sidebarProjectsDefaultExpanded = useSettings((s) => s.sidebarProjectsDefaultExpanded); const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); @@ -2777,7 +2782,7 @@ export default function Sidebar() { ), sidebarThreadSortOrder, ); - const projectExpanded = projectExpandedById[project.projectKey] ?? true; + const projectExpanded = projectExpandedById[project.projectKey] ?? sidebarProjectsDefaultExpanded; const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey @@ -2804,6 +2809,7 @@ export default function Sidebar() { }), [ sidebarThreadSortOrder, + sidebarProjectsDefaultExpanded, expandedThreadListsByProject, projectExpandedById, routeThreadKey, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f2643..acdd3ac57f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -383,6 +383,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.sidebarProjectsDefaultExpanded !== + DEFAULT_UNIFIED_SETTINGS.sidebarProjectsDefaultExpanded + ? ["Expand projects on startup"] + : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), ], @@ -394,6 +398,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.sidebarProjectsDefaultExpanded, settings.timestampFormat, theme, ], @@ -904,6 +909,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarProjectsDefaultExpanded: + DEFAULT_UNIFIED_SETTINGS.sidebarProjectsDefaultExpanded, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarProjectsDefaultExpanded: Boolean(checked) }) + } + aria-label="Expand projects on startup" + /> + } + /> + ({ @@ -242,6 +244,7 @@ function applyRecoveredEventBatch( key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), cwd: project.cwd, })), + getClientSettingsSnapshot().sidebarProjectsDefaultExpanded, ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3dc2cf9b20..8b7b472f5b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -35,7 +35,7 @@ function emitClientSettingsChange() { } } -function getClientSettingsSnapshot(): ClientSettings { +export function getClientSettingsSnapshot(): ClientSettings { return clientSettingsSnapshot; } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 3883d77c8d..6dfdb677ab 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -503,6 +503,7 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectsDefaultExpanded: false, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", @@ -531,6 +532,7 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectsDefaultExpanded: false, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", @@ -549,6 +551,7 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectsDefaultExpanded: false, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", @@ -568,6 +571,7 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectsDefaultExpanded: false, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", @@ -592,6 +596,7 @@ describe("wsApi", () => { confirmThreadDelete: false, diffWordWrap: true, sidebarProjectSortOrder: "manual", + sidebarProjectsDefaultExpanded: false, sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 7ae7232063..e5474a1f4a 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -209,7 +209,11 @@ function nestedBooleanRecordsEqual( return true; } -export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { +export function syncProjects( + state: UiState, + projects: readonly SyncProjectInput[], + defaultExpanded = false, +): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); const previousProjectIdByCwd = new Map( [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const), @@ -234,7 +238,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.cwd) - : true); + : defaultExpanded); nextExpandedById[project.key] = expanded; return { id: project.key, @@ -456,8 +460,8 @@ export function setThreadChangedFilesExpanded( }; } -export function toggleProject(state: UiState, projectId: string): UiState { - const expanded = state.projectExpandedById[projectId] ?? true; +export function toggleProject(state: UiState, projectId: string, defaultExpanded = false): UiState { + const expanded = state.projectExpandedById[projectId] ?? defaultExpanded; return { ...state, projectExpandedById: { @@ -467,8 +471,13 @@ export function toggleProject(state: UiState, projectId: string): UiState { }; } -export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { - if ((state.projectExpandedById[projectId] ?? true) === expanded) { +export function setProjectExpanded( + state: UiState, + projectId: string, + expanded: boolean, + defaultExpanded = false, +): UiState { + if ((state.projectExpandedById[projectId] ?? defaultExpanded) === expanded) { return state; } return { @@ -524,14 +533,14 @@ export function reorderProjects( } interface UiStateStore extends UiState { - syncProjects: (projects: readonly SyncProjectInput[]) => void; + syncProjects: (projects: readonly SyncProjectInput[], defaultExpanded?: boolean) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; - toggleProject: (projectId: string) => void; - setProjectExpanded: (projectId: string, expanded: boolean) => void; + toggleProject: (projectId: string, defaultExpanded?: boolean) => void; + setProjectExpanded: (projectId: string, expanded: boolean, defaultExpanded?: boolean) => void; reorderProjects: ( draggedProjectIds: readonly string[], targetProjectIds: readonly string[], @@ -540,7 +549,8 @@ interface UiStateStore extends UiState { export const useUiStateStore = create((set) => ({ ...readPersistedState(), - syncProjects: (projects) => set((state) => syncProjects(state, projects)), + syncProjects: (projects, defaultExpanded) => + set((state) => syncProjects(state, projects, defaultExpanded)), syncThreads: (threads) => set((state) => syncThreads(state, threads)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), @@ -549,9 +559,10 @@ export const useUiStateStore = create((set) => ({ clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), - toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), - setProjectExpanded: (projectId, expanded) => - set((state) => setProjectExpanded(state, projectId, expanded)), + toggleProject: (projectId, defaultExpanded) => + set((state) => toggleProject(state, projectId, defaultExpanded)), + setProjectExpanded: (projectId, expanded, defaultExpanded) => + set((state) => setProjectExpanded(state, projectId, expanded, defaultExpanded)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), })); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee56..1a5ae2e967 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -27,6 +27,9 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarProjectsDefaultExpanded: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), From 9c493cf4d1f61624664914a2f46d0b184a7ede7b Mon Sep 17 00:00:00 2001 From: Alec Ramos <60238535+alecramos-sudo@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:31:34 -0700 Subject: [PATCH 2/2] fix: remove defaultExpanded from setProjectExpanded guard The guard used `?? defaultExpanded` (defaulting to false) to compute current state, but callers without the setting would short-circuit incorrectly when the user's preference was true. Using strict equality (===) against the explicit state avoids the mismatch entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/uiStateStore.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index e5474a1f4a..cfa4c4f1e3 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -475,9 +475,8 @@ export function setProjectExpanded( state: UiState, projectId: string, expanded: boolean, - defaultExpanded = false, ): UiState { - if ((state.projectExpandedById[projectId] ?? defaultExpanded) === expanded) { + if (state.projectExpandedById[projectId] === expanded) { return state; } return { @@ -540,7 +539,7 @@ interface UiStateStore extends UiState { clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; toggleProject: (projectId: string, defaultExpanded?: boolean) => void; - setProjectExpanded: (projectId: string, expanded: boolean, defaultExpanded?: boolean) => void; + setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( draggedProjectIds: readonly string[], targetProjectIds: readonly string[], @@ -561,8 +560,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), toggleProject: (projectId, defaultExpanded) => set((state) => toggleProject(state, projectId, defaultExpanded)), - setProjectExpanded: (projectId, expanded, defaultExpanded) => - set((state) => setProjectExpanded(state, projectId, expanded, defaultExpanded)), + setProjectExpanded: (projectId, expanded) => + set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), }));