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 03ae979017..629efd19e9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -980,6 +980,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); @@ -1105,7 +1108,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) => @@ -1254,13 +1257,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, @@ -1274,9 +1278,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( @@ -2364,6 +2368,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(); @@ -2804,7 +2809,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 @@ -2831,6 +2836,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..cfa4c4f1e3 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,12 @@ 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, +): UiState { + if (state.projectExpandedById[projectId] === expanded) { return state; } return { @@ -524,13 +532,13 @@ 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; + toggleProject: (projectId: string, defaultExpanded?: boolean) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( draggedProjectIds: readonly string[], @@ -540,7 +548,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,7 +558,8 @@ 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)), + toggleProject: (projectId, defaultExpanded) => + set((state) => toggleProject(state, projectId, defaultExpanded)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (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)), ),