feat(sidebar): add single-project display mode

This commit is contained in:
Bohdan Triapitsyn
2026-08-21 16:25:51 +03:00
parent bbb2fdbe19
commit b879cf323f
30 changed files with 557 additions and 61 deletions
+4
View File
@@ -643,6 +643,10 @@ export interface SettingsPayload {
opencodeBinary?: string;
projects?: ProjectEntry[];
activeProjectId?: string;
sidebarProjectDisplayMode?: 'all' | 'single';
sidebarSessionGroupingMode?: 'by-worktree' | 'flat';
sidebarProjectSortOrder?: 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
sidebarShowRecentSection?: boolean;
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
+4
View File
@@ -65,6 +65,10 @@ export type DesktopSettings = {
desktopUiPassword?: string;
projects?: ProjectEntry[];
activeProjectId?: string;
sidebarProjectDisplayMode?: 'all' | 'single';
sidebarSessionGroupingMode?: 'by-worktree' | 'flat';
sidebarProjectSortOrder?: 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
sidebarShowRecentSection?: boolean;
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
+4
View File
@@ -2898,6 +2898,10 @@ export const dict = {
'sessions.archivePage.allDirectories': 'Alle Verzeichnisse',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Angeheftete Projektüberschriften',
'sessions.sidebar.header.grouping.label': 'Sitzungen gruppieren',
'sessions.sidebar.header.projectDisplay.label': 'Projekte anzeigen',
'sessions.sidebar.header.projectDisplay.all': 'Alle Projekte',
'sessions.sidebar.header.projectDisplay.single': 'Ein Projekt',
'sessions.sidebar.project.selectAria': 'Projekt auswählen, aktuell {project}',
'sessions.sidebar.header.grouping.byWorktree': 'Nach Worktree',
'sessions.sidebar.header.grouping.flat': 'Flache Liste',
'sessions.sidebar.project.actions.manageWorktrees': 'Worktrees verwalten',
+4
View File
@@ -442,6 +442,10 @@ export const dict = {
'sessions.archivePage.allDirectories': 'All directories',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Sticky project headers',
'sessions.sidebar.header.grouping.label': 'Group sessions',
'sessions.sidebar.header.projectDisplay.label': 'Show projects',
'sessions.sidebar.header.projectDisplay.all': 'All projects',
'sessions.sidebar.header.projectDisplay.single': 'One project',
'sessions.sidebar.project.selectAria': 'Select project, currently {project}',
'sessions.sidebar.header.grouping.byWorktree': 'By worktree',
'sessions.sidebar.header.grouping.flat': 'Flat list',
'sessions.sidebar.project.actions.manageWorktrees': 'Manage worktrees',
+4
View File
@@ -443,6 +443,10 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.allDirectories": "Todos los directorios",
"sessions.sidebar.header.displayMode.stickyHeaders": "Encabezados de proyecto fijos",
"sessions.sidebar.header.grouping.label": "Agrupar sesiones",
"sessions.sidebar.header.projectDisplay.label": "Mostrar proyectos",
"sessions.sidebar.header.projectDisplay.all": "Todos los proyectos",
"sessions.sidebar.header.projectDisplay.single": "Un proyecto",
"sessions.sidebar.project.selectAria": "Seleccionar proyecto, actualmente {project}",
"sessions.sidebar.header.grouping.byWorktree": "Por worktree",
"sessions.sidebar.header.grouping.flat": "Lista plana",
"sessions.sidebar.project.actions.manageWorktrees": "Gestionar worktrees",
+4
View File
@@ -273,6 +273,10 @@ export const dict = {
'sessions.archivePage.allDirectories': 'Tous les répertoires',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Épingler les en-têtes de projet',
'sessions.sidebar.header.grouping.label': 'Regrouper les sessions',
'sessions.sidebar.header.projectDisplay.label': 'Afficher les projets',
'sessions.sidebar.header.projectDisplay.all': 'Tous les projets',
'sessions.sidebar.header.projectDisplay.single': 'Un projet',
'sessions.sidebar.project.selectAria': 'Sélectionner un projet, actuellement {project}',
'sessions.sidebar.header.grouping.byWorktree': 'Par worktree',
'sessions.sidebar.header.grouping.flat': 'Liste plate',
'sessions.sidebar.project.actions.manageWorktrees': 'Gérer les worktrees',
+4
View File
@@ -443,6 +443,10 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.allDirectories': 'すべてのディレクトリ',
'sessions.sidebar.header.displayMode.stickyHeaders': 'プロジェクトヘッダーを固定',
'sessions.sidebar.header.grouping.label': 'セッションのグループ化',
'sessions.sidebar.header.projectDisplay.label': 'プロジェクト表示',
'sessions.sidebar.header.projectDisplay.all': 'すべてのプロジェクト',
'sessions.sidebar.header.projectDisplay.single': '1つのプロジェクト',
'sessions.sidebar.project.selectAria': 'プロジェクトを選択、現在は{project}',
'sessions.sidebar.header.grouping.byWorktree': 'ワークツリー別',
'sessions.sidebar.header.grouping.flat': 'フラットリスト',
'sessions.sidebar.project.actions.manageWorktrees': 'ワークツリーを管理',
+4
View File
@@ -443,6 +443,10 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.allDirectories': '모든 디렉터리',
'sessions.sidebar.header.displayMode.stickyHeaders': '프로젝트 헤더 고정',
'sessions.sidebar.header.grouping.label': '세션 그룹화',
'sessions.sidebar.header.projectDisplay.label': '프로젝트 표시',
'sessions.sidebar.header.projectDisplay.all': '모든 프로젝트',
'sessions.sidebar.header.projectDisplay.single': '프로젝트 하나',
'sessions.sidebar.project.selectAria': '프로젝트 선택, 현재 {project}',
'sessions.sidebar.header.grouping.byWorktree': '워크트리별',
'sessions.sidebar.header.grouping.flat': '평면 목록',
'sessions.sidebar.project.actions.manageWorktrees': '워크트리 관리',
+4
View File
@@ -254,6 +254,10 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.allDirectories': 'Wszystkie katalogi',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Przyklejone nagłówki projektów',
'sessions.sidebar.header.grouping.label': 'Grupowanie sesji',
'sessions.sidebar.header.projectDisplay.label': 'Wyświetlanie projektów',
'sessions.sidebar.header.projectDisplay.all': 'Wszystkie projekty',
'sessions.sidebar.header.projectDisplay.single': 'Jeden projekt',
'sessions.sidebar.project.selectAria': 'Wybierz projekt, obecnie {project}',
'sessions.sidebar.header.grouping.byWorktree': 'Według worktree',
'sessions.sidebar.header.grouping.flat': 'Płaska lista',
'sessions.sidebar.project.actions.manageWorktrees': 'Zarządzaj worktree',
@@ -443,6 +443,10 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.allDirectories": "Todos os diretórios",
"sessions.sidebar.header.displayMode.stickyHeaders": "Cabeçalhos de projeto fixos",
"sessions.sidebar.header.grouping.label": "Agrupar sessões",
"sessions.sidebar.header.projectDisplay.label": "Exibir projetos",
"sessions.sidebar.header.projectDisplay.all": "Todos os projetos",
"sessions.sidebar.header.projectDisplay.single": "Um projeto",
"sessions.sidebar.project.selectAria": "Selecionar projeto, atualmente {project}",
"sessions.sidebar.header.grouping.byWorktree": "Por worktree",
"sessions.sidebar.header.grouping.flat": "Lista plana",
"sessions.sidebar.project.actions.manageWorktrees": "Gerenciar worktrees",
+4
View File
@@ -443,6 +443,10 @@ export const dict: Record<I18nKey, string> = {
"sessions.archivePage.allDirectories": "Всі директорії",
"sessions.sidebar.header.displayMode.stickyHeaders": "Липкі заголовки проектів",
"sessions.sidebar.header.grouping.label": "Групування сесій",
"sessions.sidebar.header.projectDisplay.label": "Показувати проєкти",
"sessions.sidebar.header.projectDisplay.all": "Усі проєкти",
"sessions.sidebar.header.projectDisplay.single": "Один проєкт",
"sessions.sidebar.project.selectAria": "Вибрати проєкт, зараз {project}",
"sessions.sidebar.header.grouping.byWorktree": "За worktree",
"sessions.sidebar.header.grouping.flat": "Плаский список",
"sessions.sidebar.project.actions.manageWorktrees": "Керувати worktree",
@@ -443,6 +443,10 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.allDirectories': '所有目录',
'sessions.sidebar.header.displayMode.stickyHeaders': '固定项目标题',
'sessions.sidebar.header.grouping.label': '会话分组',
'sessions.sidebar.header.projectDisplay.label': '显示项目',
'sessions.sidebar.header.projectDisplay.all': '所有项目',
'sessions.sidebar.header.projectDisplay.single': '单个项目',
'sessions.sidebar.project.selectAria': '选择项目,当前为 {project}',
'sessions.sidebar.header.grouping.byWorktree': '按工作树',
'sessions.sidebar.header.grouping.flat': '平铺列表',
'sessions.sidebar.project.actions.manageWorktrees': '管理工作树',
@@ -456,6 +456,10 @@ export const dict: Record<I18nKey, string> = {
'sessions.archivePage.allDirectories': '所有目錄',
'sessions.sidebar.header.displayMode.stickyHeaders': '固定專案標題',
'sessions.sidebar.header.grouping.label': '工作階段分組',
'sessions.sidebar.header.projectDisplay.label': '顯示專案',
'sessions.sidebar.header.projectDisplay.all': '所有專案',
'sessions.sidebar.header.projectDisplay.single': '單一專案',
'sessions.sidebar.project.selectAria': '選擇專案,目前為 {project}',
'sessions.sidebar.header.grouping.byWorktree': '依工作樹',
'sessions.sidebar.header.grouping.flat': '平面清單',
'sessions.sidebar.project.actions.manageWorktrees': '管理工作樹',
+105
View File
@@ -6,6 +6,7 @@ import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import {
applyPersistedHomeDirectoryToWindow,
getRuntimeSettingsMirrorStorageKey,
@@ -443,6 +444,110 @@ describe('updateDesktopSettings', () => {
expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme');
});
test('applies authoritative shared sidebar preferences without replacing local-only sidebar state', async () => {
getWindow();
useSessionDisplayStore.setState({
projectDisplayMode: 'all',
sessionGroupingMode: 'by-worktree',
projectSortOrder: 'manual',
showRecentSection: true,
singleProjectId: 'local-project',
stickyZoneHeaders: false,
});
registerSettingsApi(async () => ({}), async () => ({
settings: {
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'recent',
sidebarShowRecentSection: false,
autoSaveEnabled: true,
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
}));
await syncDesktopSettings();
const state = useSessionDisplayStore.getState();
expect({
projectDisplayMode: state.projectDisplayMode,
sessionGroupingMode: state.sessionGroupingMode,
projectSortOrder: state.projectSortOrder,
showRecentSection: state.showRecentSection,
singleProjectId: state.singleProjectId,
stickyZoneHeaders: state.stickyZoneHeaders,
}).toEqual({
projectDisplayMode: 'single',
sessionGroupingMode: 'flat',
projectSortOrder: 'recent',
showRecentSection: false,
singleProjectId: 'local-project',
stickyZoneHeaders: false,
});
});
test('seeds missing shared sidebar preferences from the hydrated local cache', async () => {
getWindow();
const saves: Array<Partial<SettingsPayload>> = [];
useSessionDisplayStore.setState({
projectDisplayMode: 'single',
sessionGroupingMode: 'flat',
projectSortOrder: 'a-z',
showRecentSection: false,
});
registerSettingsApi(async (changes) => {
saves.push(changes);
return changes;
}, async () => ({
settings: {
autoSaveEnabled: true,
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
source: 'web',
}));
await syncDesktopSettings();
expect(saves).toEqual([{
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'a-z',
sidebarShowRecentSection: false,
}]);
});
test('preserves local sidebar preferences when the authoritative load fails', async () => {
getWindow();
useSessionDisplayStore.setState({
projectDisplayMode: 'single',
sessionGroupingMode: 'flat',
projectSortOrder: 'z-a',
showRecentSection: false,
});
registerSettingsApi(async () => ({}), async () => {
throw new Error('offline');
});
await syncDesktopSettings();
const state = useSessionDisplayStore.getState();
expect({
projectDisplayMode: state.projectDisplayMode,
sessionGroupingMode: state.sessionGroupingMode,
projectSortOrder: state.projectSortOrder,
showRecentSection: state.showRecentSection,
}).toEqual({
projectDisplayMode: 'single',
sessionGroupingMode: 'flat',
projectSortOrder: 'z-a',
showRecentSection: false,
});
});
test('applies model selector settings from server settings', async () => {
getWindow();
const settings = {
+75 -2
View File
@@ -21,6 +21,7 @@ import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
if (typeof window === 'undefined') {
@@ -63,6 +64,10 @@ const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: str
homeDirectory: settings.homeDirectory,
projects: settings.projects,
activeProjectId: settings.activeProjectId,
sidebarProjectDisplayMode: settings.sidebarProjectDisplayMode,
sidebarSessionGroupingMode: settings.sidebarSessionGroupingMode,
sidebarProjectSortOrder: settings.sidebarProjectSortOrder,
sidebarShowRecentSection: settings.sidebarShowRecentSection,
pinnedDirectories: settings.pinnedDirectories,
gitmojiEnabled: settings.gitmojiEnabled,
directoryShowHidden: settings.directoryShowHidden,
@@ -1029,6 +1034,26 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.filesViewShowGitignored === 'boolean') {
setFilesViewShowGitignored(settings.filesViewShowGitignored, { persist: false });
}
const sessionDisplayChanges: Partial<ReturnType<typeof useSessionDisplayStore.getState>> = {};
if (settings.sidebarProjectDisplayMode === 'all' || settings.sidebarProjectDisplayMode === 'single') {
sessionDisplayChanges.projectDisplayMode = settings.sidebarProjectDisplayMode;
}
if (settings.sidebarSessionGroupingMode === 'by-worktree' || settings.sidebarSessionGroupingMode === 'flat') {
sessionDisplayChanges.sessionGroupingMode = settings.sidebarSessionGroupingMode;
}
if (settings.sidebarProjectSortOrder === 'manual'
|| settings.sidebarProjectSortOrder === 'a-z'
|| settings.sidebarProjectSortOrder === 'z-a'
|| settings.sidebarProjectSortOrder === 'date-added'
|| settings.sidebarProjectSortOrder === 'recent') {
sessionDisplayChanges.projectSortOrder = settings.sidebarProjectSortOrder;
}
if (typeof settings.sidebarShowRecentSection === 'boolean') {
sessionDisplayChanges.showRecentSection = settings.sidebarShowRecentSection;
}
if (Object.keys(sessionDisplayChanges).length > 0) {
useSessionDisplayStore.setState(sessionDisplayChanges);
}
};
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
@@ -1085,6 +1110,22 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (candidate.sidebarProjectDisplayMode === 'all' || candidate.sidebarProjectDisplayMode === 'single') {
result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode;
}
if (candidate.sidebarSessionGroupingMode === 'by-worktree' || candidate.sidebarSessionGroupingMode === 'flat') {
result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode;
}
if (candidate.sidebarProjectSortOrder === 'manual'
|| candidate.sidebarProjectSortOrder === 'a-z'
|| candidate.sidebarProjectSortOrder === 'z-a'
|| candidate.sidebarProjectSortOrder === 'date-added'
|| candidate.sidebarProjectSortOrder === 'recent') {
result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
}
if (typeof candidate.sidebarShowRecentSection === 'boolean') {
result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = candidate.securityScopedBookmarks.filter(
@@ -1747,12 +1788,12 @@ export const syncDesktopSettings = async (): Promise<void> => {
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
const persistApi = getPersistApi();
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
// Wait for Zustand persist hydration before applying server settings.
// Otherwise `set()`-calls race with hydration: we set X, then hydration
// reads localStorage and overwrites back to the persisted value.
const waitForHydration = (): Promise<void> => {
const waitForPersistHydration = (persistApi: PersistApi | undefined): Promise<void> => {
if (!persistApi?.hasHydrated || persistApi.hasHydrated()) {
return Promise.resolve();
}
@@ -1775,6 +1816,9 @@ export const syncDesktopSettings = async (): Promise<void> => {
if (persistApi.hasHydrated?.()) finish();
});
};
const waitForHydration = (): Promise<void> => Promise.all(
persistApis.map(waitForPersistHydration),
).then(() => undefined);
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
// a TypeError from writing to a contextBridge-protected global) doesn't
@@ -1789,6 +1833,10 @@ export const syncDesktopSettings = async (): Promise<void> => {
// `openchamber:files:auto-save-enabled`. Prefer the hydrated store value and
// seed the backend once so later omitted→default authority is correct.
const shouldSeedAutoSaveEnabled = typeof settings.autoSaveEnabled !== 'boolean';
const shouldSeedSidebarProjectDisplayMode = settings.sidebarProjectDisplayMode === undefined;
const shouldSeedSidebarSessionGroupingMode = settings.sidebarSessionGroupingMode === undefined;
const shouldSeedSidebarProjectSortOrder = settings.sidebarProjectSortOrder === undefined;
const shouldSeedSidebarShowRecentSection = settings.sidebarShowRecentSection === undefined;
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
try {
persistToLocalStorage(settings);
@@ -1800,6 +1848,19 @@ export const syncDesktopSettings = async (): Promise<void> => {
if (shouldSeedAutoSaveEnabled) {
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
}
const sessionDisplayState = useSessionDisplayStore.getState();
if (shouldSeedSidebarProjectDisplayMode) {
authoritativeSettings.sidebarProjectDisplayMode = sessionDisplayState.projectDisplayMode;
}
if (shouldSeedSidebarSessionGroupingMode) {
authoritativeSettings.sidebarSessionGroupingMode = sessionDisplayState.sessionGroupingMode;
}
if (shouldSeedSidebarProjectSortOrder) {
authoritativeSettings.sidebarProjectSortOrder = sessionDisplayState.projectSortOrder;
}
if (shouldSeedSidebarShowRecentSection) {
authoritativeSettings.sidebarShowRecentSection = sessionDisplayState.showRecentSection;
}
if (settings.draftStarters === undefined) {
useUIStore.setState({ globalDraftStarters: null });
}
@@ -1819,6 +1880,18 @@ export const syncDesktopSettings = async (): Promise<void> => {
if (shouldSeedAutoSaveEnabled) {
migrationPatch.autoSaveEnabled = authoritativeSettings.autoSaveEnabled;
}
if (shouldSeedSidebarProjectDisplayMode) {
migrationPatch.sidebarProjectDisplayMode = authoritativeSettings.sidebarProjectDisplayMode;
}
if (shouldSeedSidebarSessionGroupingMode) {
migrationPatch.sidebarSessionGroupingMode = authoritativeSettings.sidebarSessionGroupingMode;
}
if (shouldSeedSidebarProjectSortOrder) {
migrationPatch.sidebarProjectSortOrder = authoritativeSettings.sidebarProjectSortOrder;
}
if (shouldSeedSidebarShowRecentSection) {
migrationPatch.sidebarShowRecentSection = authoritativeSettings.sidebarShowRecentSection;
}
if (Object.keys(migrationPatch).length > 0) {
await updateDesktopSettings(migrationPatch);
if (!isSettingsRuntimeContextCurrent(context)) return;