From b879cf323f2625964eca20c9f7b1771d774b8a2f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 16:25:51 +0300 Subject: [PATCH] feat(sidebar): add single-project display mode --- .../src/components/session/SessionSidebar.tsx | 42 +++++-- .../session/sidebar/DOCUMENTATION.md | 1 + .../session/sidebar/SessionGroupSection.tsx | 9 +- .../session/sidebar/SidebarHeader.tsx | 67 +++++++++-- .../session/sidebar/SidebarProjectsList.tsx | 39 ++++-- .../session/sidebar/sortableItems.tsx | 112 ++++++++++++++---- packages/ui/src/lib/api/types.ts | 4 + packages/ui/src/lib/desktop.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + packages/ui/src/lib/persistence.test.ts | 105 ++++++++++++++++ packages/ui/src/lib/persistence.ts | 77 +++++++++++- packages/ui/src/stores/DOCUMENTATION.md | 2 + .../src/stores/useSessionDisplayStore.test.ts | 23 ++++ .../ui/src/stores/useSessionDisplayStore.ts | 14 ++- packages/ui/src/sync/session-ui-store.test.js | 13 ++ packages/ui/src/sync/session-ui-store.ts | 4 + .../web/server/lib/opencode/DOCUMENTATION.md | 3 +- .../server/lib/opencode/settings-helpers.js | 15 +++ .../lib/opencode/settings-helpers.test.js | 22 ++++ .../lib/opencode/settings-runtime.test.js | 18 +++ 30 files changed, 557 insertions(+), 61 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 11922074..0e2d9c8a 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -102,6 +102,7 @@ import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug'; import { runBackgroundNetworkTask } from '@/lib/background-network'; +import { isCapacitorApp } from '@/lib/platform'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; @@ -924,10 +925,10 @@ const SessionSidebarComponent: React.FC = ({ const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession); const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename); - const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => { + const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number, increment: number = 7) => { setVisibleSessionCountByGroup((prev) => { const next = new Map(prev); - next.set(groupId, currentVisibleCount + 7); + next.set(groupId, currentVisibleCount + increment); return next; }); }, []); @@ -1134,10 +1135,16 @@ const SessionSidebarComponent: React.FC = ({ } const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection); + const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode); + const singleProjectId = useSessionDisplayStore((state) => state.singleProjectId); + const setSingleProjectId = useSessionDisplayStore((state) => state.setSingleProjectId); const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder); + const supportsSingleProjectMode = !isVSCode && !isCapacitorApp(); + const isSingleProjectMode = projectDisplayMode === 'single' && supportsSingleProjectMode; + const shouldShowRecentSection = showRecentSection && !isSingleProjectMode; const projectExpandedParentsRef = React.useRef>(new Set()); const recentExpandedParentsRef = React.useRef>(new Set()); const projectExpandedParents = selectExpandedParentKeysForContext( @@ -1182,7 +1189,7 @@ const SessionSidebarComponent: React.FC = ({ githubAuthStatus, githubAuthChecked, updateStore, - showRecentSection, + showRecentSection: shouldShowRecentSection, showArchivedSessions, projectSortOrder, projectRepoStatus, @@ -1362,13 +1369,13 @@ const SessionSidebarComponent: React.FC = ({ }, [projectSections, homeDirectory]); const recentSessions = React.useMemo(() => { - if (!showRecentSection || isVSCode) { + if (!shouldShowRecentSection || isVSCode) { return []; } return deriveRecentSessions(sessions.filter((session) => !isChatDirectoryForHome(session.directory, homeDirectory)), activeSessionIdSet) .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); - }, [activeSessionIdSet, homeDirectory, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + }, [activeSessionIdSet, homeDirectory, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, shouldShowRecentSection]); const chatSessions = React.useMemo(() => sessions .filter((session) => !session.parentID && !session.time?.archived && isChatDirectoryForHome(session.directory, homeDirectory)) @@ -1409,7 +1416,7 @@ const SessionSidebarComponent: React.FC = ({ }; }; - const recentItems = showRecentSection ? recentSessions + const recentItems = shouldShowRecentSection ? recentSessions .map(toItem) .filter((item): item is NonNullable> => item !== null) : []; @@ -1420,7 +1427,7 @@ const SessionSidebarComponent: React.FC = ({ { key: 'chats' as const, title: t('sessions.sidebar.activity.chatsTitle'), items: chatItems }, { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: recentItems }, ]; - }, [chatSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); + }, [chatSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, shouldShowRecentSection, t]); const hasActivitySectionItems = React.useMemo( () => activitySections.some((section) => section.key === 'chats' || section.items.length > 0), @@ -1446,6 +1453,19 @@ const SessionSidebarComponent: React.FC = ({ : section )); }, [flatSectionsForRender, sectionsForRender, showInlineArchived, useGroupedSections]); + const effectiveSingleProjectId = React.useMemo(() => { + if (!isSingleProjectMode) return null; + if (singleProjectId && projectSections.some((section) => section.project.id === singleProjectId)) { + return singleProjectId; + } + if (activeProjectId && projectSections.some((section) => section.project.id === activeProjectId)) { + return activeProjectId; + } + return projectSections[0]?.project.id ?? null; + }, [activeProjectId, isSingleProjectMode, projectSections, singleProjectId]); + const handleSingleProjectSelect = React.useCallback((projectId: string) => { + setSingleProjectId(projectId); + }, [setSingleProjectId]); // Discover/refresh PR status for expanded projects' worktree branches so // session rows can tint their branch marker and show PR state in tooltips. @@ -1665,6 +1685,9 @@ const SessionSidebarComponent: React.FC = ({ normalizedSessionSearchQuery={normalizedSessionSearchQuery} groupSearchDataByGroup={groupSearchDataByGroup} visibleSessionCount={visibleSessionCountByGroup.get(groupKey)} + sessionBatchSize={isSingleProjectMode && sessionGroupingMode === 'flat' && group.id !== 'managed-chats' + ? 20 + : undefined} collapsedGroups={collapsedGroups} hideDirectoryControls={hideDirectoryControls} collapsedFolderIds={collapsedFolderIds} @@ -1853,6 +1876,7 @@ const SessionSidebarComponent: React.FC = ({ { @@ -1885,7 +1909,11 @@ const SessionSidebarComponent: React.FC = ({ hasSharedSessions={hasActivitySectionItems} sectionsForRender={sectionsForSidebarRender} projectSections={projectSections} + projectPickerSections={projectSections} activeProjectId={activeProjectId} + singleProjectMode={isSingleProjectMode} + singleProjectId={effectiveSingleProjectId} + setSingleProjectId={handleSingleProjectSelect} showOnlyMainWorkspace={showOnlyMainWorkspace} hasSessionSearchQuery={hasSessionSearchQuery} emptyState={emptyState} diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index f3e66647..044dc3d6 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -5,6 +5,7 @@ - `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components. - Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level. - **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling. +- **Project display is independent from grouping.** `'all'` keeps every project zone; `'single'` is web/desktop/PWA-only and renders one selected project under the always-present Chats section. Its project header is a non-collapsible picker ordered by the current project sort. Recent and collapse/expand-all controls are hidden without changing their persisted preferences. Opening a materialized project session updates the picker from the session's confirmed directory; changing only a draft target does not. In `'single'` + `'flat'`, active sessions reveal in batches of 20. `'single'` + `'by-worktree'` retains the ordinary per-group limits. Project display mode, session grouping, project sort, and the Recent preference are server-backed shared settings with the hydrated browser store as the migration/failure cache. The selected single project and sticky-header preference remain device-local. - When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories. - **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state. - Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread. diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 32796fce..752c393c 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -58,6 +58,7 @@ type Props = { normalizedSessionSearchQuery: string; groupSearchDataByGroup: WeakMap; visibleSessionCount?: number; + sessionBatchSize?: number; collapsedGroups: Set; hideDirectoryControls: boolean; collapsedFolderIds: Set; @@ -76,7 +77,7 @@ type Props = { renderContext?: 'project' | 'recent', renderExtras?: SessionNodeRenderExtras, ) => React.ReactNode; - showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void; + showMoreGroupSessions: (groupKey: string, currentVisibleCount: number, increment?: number) => void; resetGroupSessionLimit: (groupKey: string) => void; mobileVariant: boolean; alwaysShowActions: boolean; @@ -190,6 +191,7 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => { if (prev.compactBodyPadding !== next.compactBodyPadding) return false; if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false; if (prev.visibleSessionCount !== next.visibleSessionCount) return false; + if (prev.sessionBatchSize !== next.sessionBatchSize) return false; if (prev.collapsedGroups !== next.collapsedGroups && prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) { @@ -288,6 +290,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { normalizedSessionSearchQuery, groupSearchDataByGroup, visibleSessionCount, + sessionBatchSize, collapsedGroups, hideDirectoryControls, collapsedFolderIds, @@ -401,7 +404,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { setIsRequestingBootstrapAccess(false); } }, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]); - const maxVisible = hideDirectoryControls ? 10 : 5; + const maxVisible = sessionBatchSize ?? (hideDirectoryControls ? 10 : 5); const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible); const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false; const shouldFilterGroupContents = hasSessionSearchQuery; @@ -1059,7 +1062,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {remainingCount > 0 ? ( - - - {projectDescription} - - + + + + + + {projectPickerOptions?.map((option) => ( + onProjectSelect?.(option.id)} + className="flex items-center justify-between gap-3" + title={option.projectDescription} + > + + + + {option.id === id ? : null} + + ))} + + + ) : ( + + + + + + {projectDescription} + + + )}
= { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 40e496bf..94d915e4 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 980973e3..0a2529c5 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -443,6 +443,10 @@ export const dict: Record = { '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': 'ワークツリーを管理', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 6b409fdf..fc164a8f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -443,6 +443,10 @@ export const dict: Record = { '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': '워크트리 관리', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 33ba4418..ba9bc45e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -254,6 +254,10 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 48904a98..f13f1748 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -443,6 +443,10 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 110ceb18..a6867e50 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -443,6 +443,10 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index fdfc55a7..71c92446 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -443,6 +443,10 @@ export const dict: Record = { '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': '管理工作树', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index aad95ad1..58c52dce 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -456,6 +456,10 @@ export const dict: Record = { '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': '管理工作樹', diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index bd59bcfe..d2c4dfe5 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -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> = []; + 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 = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 1e6ad415..a3ed076b 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -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> = {}; + 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 => { 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 => { + const waitForPersistHydration = (persistApi: PersistApi | undefined): Promise => { if (!persistApi?.hasHydrated || persistApi.hasHydrated()) { return Promise.resolve(); } @@ -1775,6 +1816,9 @@ export const syncDesktopSettings = async (): Promise => { if (persistApi.hasHydrated?.()) finish(); }); }; + const waitForHydration = (): Promise => 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 => { // `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 => { 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 => { 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; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index c815db70..6ef48876 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -88,6 +88,8 @@ Project and UI settings use successful settings synchronization as authority. Om Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode. +Session display persistence keeps a hydrated local cache for the independent all-projects/single-project mode, session grouping, project sort, and Recent preference; successful server settings snapshots are authoritative and the UI seeds missing server fields once from that cache for upgrades. The last confirmed or manually selected project and sticky-header preference stay local to the device. Draft target changes do not write the picker selection; materialized session navigation updates it from the resolved project directory. + Session folders persist in runtime-specific v2 browser keys without silently evicting older runtime namespaces. Runtime switch, page hide, app freeze, and unload synchronously flush the pending browser snapshot before lifecycle suspension or namespace replacement. A runtime switch then cancels stale old-runtime disk work and starts generation-owned disk hydration. Missing or malformed server files are not authoritative empty snapshots; disk data may replace browser state only when it carries a real revision and no newer local folder mutation occurred. Server writes are serialized and reject non-newer revisions so delayed or duplicate requests cannot overwrite the current state. File-search cache and in-flight keys include runtime plus directory and are cleared on endpoint reset. Persisted session todos use a bounded composite key of runtime, normalized directory, and session ID. Ambiguous legacy todo entries are discarded rather than claimed by whichever runtime starts first. Authoritative deletion uses an explicit runtime identity, and session-folder deletion scans every scope in the active runtime so archived assignments cannot survive after their session is gone. diff --git a/packages/ui/src/stores/useSessionDisplayStore.test.ts b/packages/ui/src/stores/useSessionDisplayStore.test.ts index 6c5c0201..8fcfeca3 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.test.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.test.ts @@ -32,3 +32,26 @@ describe('useSessionDisplayStore project sorting', () => { expect(migrated.showArchivedSessions).toBe(true); }); }); + +describe('useSessionDisplayStore project display', () => { + test('defaults to showing all projects without a selected single project', () => { + expect(useSessionDisplayStore.getState().projectDisplayMode).toBe('all'); + expect(useSessionDisplayStore.getState().singleProjectId).toBeNull(); + }); + + test('stores the single-project mode independently from session grouping', () => { + useSessionDisplayStore.getState().setProjectDisplayMode('single'); + useSessionDisplayStore.getState().setSingleProjectId('project-alpha'); + useSessionDisplayStore.getState().setSessionGroupingMode('flat'); + + expect(useSessionDisplayStore.getState().projectDisplayMode).toBe('single'); + expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha'); + expect(useSessionDisplayStore.getState().sessionGroupingMode).toBe('flat'); + + useSessionDisplayStore.setState({ + projectDisplayMode: 'all', + singleProjectId: null, + sessionGroupingMode: 'by-worktree', + }); + }); +}); diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts index 0cb7ca84..656d7870 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -6,8 +6,13 @@ type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent'; // 'by-worktree' keeps per-worktree sub-headers inside each project zone // (parallel-work overview); 'flat' merges everything into one recency list. type SessionGroupingMode = 'by-worktree' | 'flat'; +type ProjectDisplayMode = 'all' | 'single'; type SessionDisplayStore = { + projectDisplayMode: ProjectDisplayMode; + singleProjectId: string | null; + setProjectDisplayMode: (mode: ProjectDisplayMode) => void; + setSingleProjectId: (projectId: string) => void; sessionGroupingMode: SessionGroupingMode; setSessionGroupingMode: (mode: SessionGroupingMode) => void; /** Project/recent zone headers stick to the top while their zone scrolls. */ @@ -50,6 +55,10 @@ export const migrateSessionDisplayState = ( export const useSessionDisplayStore = create()( persist( (set) => ({ + projectDisplayMode: 'all', + singleProjectId: null, + setProjectDisplayMode: (mode) => set({ projectDisplayMode: mode }), + setSingleProjectId: (projectId) => set({ singleProjectId: projectId }), sessionGroupingMode: 'by-worktree', setSessionGroupingMode: (mode) => set({ sessionGroupingMode: mode }), stickyZoneHeaders: true, @@ -68,13 +77,14 @@ export const useSessionDisplayStore = create()( }), { name: 'session-display-mode', - version: 4, + version: 5, // v1→v2 adds projectSortOrder using the canonical manual ordering. // v2→v3 replaces the previously shipped recent default with manual. // v3→v4 removes displayMode (single sidebar row layout). + // v4→v5 adds the independent all-projects/single-project view mode. migrate: migrateSessionDisplayState, }, ), ); -export type { ProjectSortOrder }; +export type { ProjectDisplayMode, ProjectSortOrder }; diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 6f2fdc5e..6de1cf68 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -10,6 +10,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; /** * Unit tests for session worktree routing through the authoritative store. @@ -656,9 +657,19 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => { currentSessionDirectory: null, newSessionDraft: { open: false, directoryOverride: null, parentID: null }, }); + useProjectsStore.setState({ projects: [], activeProjectId: null }); + useSessionDisplayStore.setState({ singleProjectId: null }); }); test('draft send snapshots the draft; switching to another project mid-flight still targets the materialized session', async () => { + useProjectsStore.setState({ + projects: [ + { id: 'project-alpha', path: '/projects/alpha', label: 'Alpha' }, + { id: 'project-beta', path: '/projects/beta', label: 'Beta' }, + ], + activeProjectId: 'project-alpha', + }); + useSessionDisplayStore.setState({ singleProjectId: 'project-alpha' }); const draftSnapshot = { open: true, directoryOverride: '/projects/alpha', @@ -686,6 +697,7 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => { // A sidebar switch while the send is still in flight must not reroute it. useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta'); + expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-beta'); await sendPromise; @@ -694,6 +706,7 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => { expect(sendMessageCalls).toHaveLength(1); expect(sendMessageCalls[0].id).toBe('session-materialized'); expect(sendMessageCalls[0].directory).toBe('/projects/alpha'); + expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha'); }); test('existing-session send keeps the submit-time target even when selection changes', async () => { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index ff337e17..1ca42e04 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -20,6 +20,7 @@ import { opencodeClient } from "@/lib/opencode/client" import { runtimeFetch } from "@/lib/runtime-fetch" import { useConfigStore } from "@/stores/useConfigStore" import { useProjectsStore } from "@/stores/useProjectsStore" +import { useSessionDisplayStore } from "@/stores/useSessionDisplayStore" import { fetchSessionKnowledge, reportSessionKnowledgeDelivered } from "@/lib/sessionKnowledgeApi" import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore" import { useDirectoryStore } from "@/stores/useDirectoryStore" @@ -914,6 +915,9 @@ export const useSessionUIStore = create()((set, get) => ({ if (sessionProject && projectsState.activeProjectId !== sessionProject.id) { projectsState.setActiveProjectIdOnly(sessionProject.id) } + if (id && !isGuessedDir && sessionProject) { + useSessionDisplayStore.getState().setSingleProjectId(sessionProject.id) + } opencodeClient.setDirectory(resolvedDir ?? undefined) } catch (e) { console.warn("Failed to set OpenCode directory for session switch:", e) diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index ae4042cc..49cd06a8 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -207,7 +207,8 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn - `readSettingsFromDiskMigrated()` - `writeSettingsToDisk(settings)` - `persistSettings(changes)` - - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`. +- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`. +- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`. ## Public exports (settings-helpers.js) - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping. diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 5095806a..9e0baff0 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -29,6 +29,9 @@ export const createSettingsHelpers = (dependencies) => { const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']); const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']); const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']); + const SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']); + const SIDEBAR_SESSION_GROUPING_MODE_VALUES = new Set(['by-worktree', 'flat']); + const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']); const HIDDEN_MODELS_MAX = 1024; const RECENT_EFFORTS_MAX_KEYS = 128; const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5; @@ -243,6 +246,18 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { result.activeProjectId = candidate.activeProjectId; } + if (SIDEBAR_PROJECT_DISPLAY_MODE_VALUES.has(candidate.sidebarProjectDisplayMode)) { + result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode; + } + if (SIDEBAR_SESSION_GROUPING_MODE_VALUES.has(candidate.sidebarSessionGroupingMode)) { + result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode; + } + if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) { + result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder; + } + if (typeof candidate.sidebarShowRecentSection === 'boolean') { + result.sidebarShowRecentSection = candidate.sidebarShowRecentSection; + } if (Array.isArray(candidate.securityScopedBookmarks)) { result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks); diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 543c5531..5c44cedf 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -66,6 +66,28 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({}); }); + it('sanitizes shared sidebar display preferences', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ + sidebarProjectDisplayMode: 'single', + sidebarSessionGroupingMode: 'flat', + sidebarProjectSortOrder: 'z-a', + sidebarShowRecentSection: false, + })).toEqual({ + sidebarProjectDisplayMode: 'single', + sidebarSessionGroupingMode: 'flat', + sidebarProjectSortOrder: 'z-a', + sidebarShowRecentSection: false, + }); + expect(helpers.sanitizeSettingsUpdate({ + sidebarProjectDisplayMode: 'grid', + sidebarSessionGroupingMode: 'project', + sidebarProjectSortOrder: 'random', + sidebarShowRecentSection: 'false', + })).toEqual({}); + }); + it('accepts only booleans for wide chat layout', () => { const helpers = createTestHelpers(); diff --git a/packages/web/server/lib/opencode/settings-runtime.test.js b/packages/web/server/lib/opencode/settings-runtime.test.js index 7a6b8e69..7c774d8e 100644 --- a/packages/web/server/lib/opencode/settings-runtime.test.js +++ b/packages/web/server/lib/opencode/settings-runtime.test.js @@ -39,6 +39,24 @@ const createRuntime = async () => { }; describe('settings runtime', () => { + it('round-trips shared sidebar preferences through settings.json', async () => { + const { runtime, settingsFilePath, cleanup } = await createRuntime(); + const preferences = { + sidebarProjectDisplayMode: 'single', + sidebarSessionGroupingMode: 'flat', + sidebarProjectSortOrder: 'date-added', + sidebarShowRecentSection: false, + }; + try { + await runtime.persistSettings(preferences); + + await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences); + await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2)); + } finally { + await cleanup(); + } + }); + it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => { const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime(); try {