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
@@ -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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
}
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<Set<string>>(new Set());
const recentExpandedParentsRef = React.useRef<Set<string>>(new Set());
const projectExpandedParents = selectExpandedParentKeysForContext(
@@ -1182,7 +1189,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
githubAuthStatus,
githubAuthChecked,
updateStore,
showRecentSection,
showRecentSection: shouldShowRecentSection,
showArchivedSessions,
projectSortOrder,
projectRepoStatus,
@@ -1362,13 +1369,13 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}, [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<SessionSidebarProps> = ({
};
};
const recentItems = showRecentSection ? recentSessions
const recentItems = shouldShowRecentSection ? recentSessions
.map(toItem)
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => item !== null) : [];
@@ -1420,7 +1427,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
{ 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<SessionSidebarProps> = ({
: 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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
showProjectDisplayControls={supportsSingleProjectMode}
showRecentControls={!isVSCode}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
onOpenScheduled={() => {
@@ -1885,7 +1909,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
projectPickerSections={projectSections}
activeProjectId={activeProjectId}
singleProjectMode={isSingleProjectMode}
singleProjectId={effectiveSingleProjectId}
setSingleProjectId={handleSingleProjectSelect}
showOnlyMainWorkspace={showOnlyMainWorkspace}
hasSessionSearchQuery={hasSessionSearchQuery}
emptyState={emptyState}
@@ -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.
@@ -58,6 +58,7 @@ type Props = {
normalizedSessionSearchQuery: string;
groupSearchDataByGroup: WeakMap<SessionGroup, GroupSearchData>;
visibleSessionCount?: number;
sessionBatchSize?: number;
collapsedGroups: Set<string>;
hideDirectoryControls: boolean;
collapsedFolderIds: Set<string>;
@@ -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 ? (
<button
type="button"
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length, sessionBatchSize ?? 7)}
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showMore')}
@@ -13,9 +13,11 @@ import { Icon } from "@/components/icon/Icon";
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useI18n } from '@/lib/i18n';
import { updateDesktopSettings } from '@/lib/persistence';
type Props = {
hideDirectoryControls: boolean;
showProjectDisplayControls: boolean;
showRecentControls: boolean;
handleOpenDirectoryDialog: () => void;
onOpenScheduled: () => void;
@@ -41,6 +43,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
const { t } = useI18n();
const {
hideDirectoryControls,
showProjectDisplayControls,
showRecentControls,
handleOpenDirectoryDialog,
onOpenScheduled,
@@ -70,6 +73,9 @@ export function SidebarHeader(props: Props): React.ReactNode {
const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode);
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders);
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
const setProjectDisplayMode = useSessionDisplayStore((state) => state.setProjectDisplayMode);
const isSingleProjectMode = showProjectDisplayControls && projectDisplayMode === 'single';
if (hideDirectoryControls) {
return null;
@@ -205,7 +211,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
] as const).map(([order, labelKey]) => (
<DropdownMenuItem
key={order}
onClick={() => setProjectSortOrder(order)}
onClick={() => {
setProjectSortOrder(order);
void updateDesktopSettings({ sidebarProjectSortOrder: order });
}}
className="flex items-center justify-between"
>
<span>{t(labelKey)}</span>
@@ -213,6 +222,28 @@ export function SidebarHeader(props: Props): React.ReactNode {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{showProjectDisplayControls ? (
<>
<DropdownMenuLabel>{t('sessions.sidebar.header.projectDisplay.label')}</DropdownMenuLabel>
{([
['all', 'sessions.sidebar.header.projectDisplay.all'],
['single', 'sessions.sidebar.header.projectDisplay.single'],
] as const).map(([mode, labelKey]) => (
<DropdownMenuItem
key={mode}
onClick={() => {
setProjectDisplayMode(mode);
void updateDesktopSettings({ sidebarProjectDisplayMode: mode });
}}
className="flex items-center justify-between"
>
<span>{t(labelKey)}</span>
{projectDisplayMode === mode ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuLabel>{t('sessions.sidebar.header.grouping.label')}</DropdownMenuLabel>
{([
['by-worktree', 'sessions.sidebar.header.grouping.byWorktree'],
@@ -220,7 +251,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
] as const).map(([mode, labelKey]) => (
<DropdownMenuItem
key={mode}
onClick={() => setSessionGroupingMode(mode)}
onClick={() => {
setSessionGroupingMode(mode);
void updateDesktopSettings({ sidebarSessionGroupingMode: mode });
}}
className="flex items-center justify-between"
>
<span>{t(labelKey)}</span>
@@ -228,9 +262,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{showRecentControls ? (
{showRecentControls && !isSingleProjectMode ? (
<DropdownMenuItem
onClick={toggleRecentSection}
onClick={() => {
toggleRecentSection();
void updateDesktopSettings({ sidebarShowRecentSection: !showRecentSection });
}}
className="flex items-center justify-between"
>
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
@@ -244,15 +281,19 @@ export function SidebarHeader(props: Props): React.ReactNode {
<span>{t('sessions.sidebar.header.displayMode.stickyHeaders')}</span>
{stickyZoneHeaders ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
<Icon name="contract-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
<Icon name="expand-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
</DropdownMenuItem>
{!isSingleProjectMode ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
<Icon name="contract-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
<Icon name="expand-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -59,7 +59,11 @@ type Props = {
hasSharedSessions?: boolean;
sectionsForRender: ProjectSection[];
projectSections: ProjectSection[];
projectPickerSections: ProjectSection[];
activeProjectId: string | null;
singleProjectMode: boolean;
singleProjectId: string | null;
setSingleProjectId: (id: string) => void;
showOnlyMainWorkspace: boolean;
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
@@ -105,7 +109,7 @@ type Props = {
function SidebarProjectsListComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n();
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders;
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders && !props.singleProjectMode;
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -113,6 +117,21 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
const groupSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const selectedSingleProjectSection = props.singleProjectMode
? props.sectionsForRender.find((section) => section.project.id === props.singleProjectId)
: null;
const renderedProjectSections = props.singleProjectMode
? (selectedSingleProjectSection ? [selectedSingleProjectSection] : [])
: props.sectionsForRender;
const projectPickerOptions = React.useMemo(() => props.projectPickerSections.map((section) => ({
id: section.project.id,
projectLabel: getProjectLabel(section.project, props.homeDirectory),
projectDescription: formatPathForDisplay(section.project.normalizedPath, props.homeDirectory),
projectIcon: section.project.icon,
projectColor: section.project.color,
projectIconImage: section.project.iconImage,
projectIconBackground: section.project.iconBackground,
})), [props.homeDirectory, props.projectPickerSections]);
// Memoize getOrderedGroups per project so downstream consumers see a stable
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
@@ -169,7 +188,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
event.preventDefault();
event.stopPropagation();
}, []);
const hasProjectScroller = props.projectSections.length > 0 && props.sectionsForRender.length > 0;
const hasProjectScroller = props.projectSections.length > 0 && renderedProjectSections.length > 0;
React.useLayoutEffect(() => {
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
syncTopFade(scrollContainerRef.current);
@@ -220,7 +239,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// ready in the same frame; the observer then corrects it. When shared sessions
// lead the list, the Recent fallback below owns the top instead of a project.
const leadingProject =
stuckProject ?? (props.hasSharedSessions ? null : props.sectionsForRender[0]?.project ?? null);
stuckProject ?? (props.hasSharedSessions ? null : renderedProjectSections[0]?.project ?? null);
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
if (props.sharedSessionsOnly) {
@@ -311,20 +330,20 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
props.reorderProjects(oldIndex, newIndex);
}}
>
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
{props.sectionsForRender.map((section) => {
<SortableContext items={renderedProjectSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
{renderedProjectSections.map((section) => {
const project = section.project;
const projectKey = project.id;
const projectLabel = getProjectLabel(project, props.homeDirectory);
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
const isCollapsed = props.collapsedProjects.has(projectKey);
const isCollapsed = props.singleProjectMode ? false : props.collapsedProjects.has(projectKey);
const isRepo = props.projectRepoStatus.get(projectKey);
return (
<SortableProjectItem
key={projectKey}
id={projectKey}
disabled={props.projectSortOrder !== 'manual'}
disabled={props.singleProjectMode || props.projectSortOrder !== 'manual'}
projectLabel={projectLabel}
projectDescription={projectDescription}
projectIcon={project.icon}
@@ -338,7 +357,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
mobileVariant={props.mobileVariant}
alwaysShowActions={props.alwaysShowActions}
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
onToggle={() => props.toggleProject(projectKey)}
onToggle={() => {
if (!props.singleProjectMode) props.toggleProject(projectKey);
}}
onNewSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
@@ -360,6 +381,8 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
showCreateButtons
openSidebarMenuKey={props.openSidebarMenuKey}
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
projectPickerOptions={props.singleProjectMode ? projectPickerOptions : undefined}
onProjectSelect={props.singleProjectMode ? props.setSingleProjectId : undefined}
>
{!isCollapsed ? (
<div className="space-y-0 pt-0.5 pb-0.5">
@@ -30,6 +30,10 @@ type ProjectIdentityProps = {
projectIconBackground?: string;
};
type ProjectPickerOption = ProjectIdentityProps & {
projectDescription: string;
};
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
isCollapsed?: boolean;
alwaysShowActions?: boolean;
@@ -121,6 +125,8 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
setOpenSidebarMenuKey: (key: string | null) => void;
/** Aggregated activity/attention indicator shown while the project is collapsed. */
statusIndicator?: React.ReactNode;
projectPickerOptions?: ProjectPickerOption[];
onProjectSelect?: (projectId: string) => void;
}
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
@@ -150,6 +156,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
openSidebarMenuKey,
setOpenSidebarMenuKey,
statusIndicator = null,
projectPickerOptions,
onProjectSelect,
}) => {
const { t } = useI18n();
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
@@ -227,6 +235,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
}
onToggle();
}, [onToggle]);
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
return (
<div
@@ -273,39 +282,92 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
{...attributes}
>
<Tooltip>
<TooltipTrigger asChild>
{isProjectPicker ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onMouseDown={handleToggleMouseDown}
onClick={handleToggleClick}
{...listeners}
title={projectDescription}
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md transition-[padding]',
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
isCollapsed={isCollapsed}
alwaysShowActions={alwaysShowActions}
/>
{statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
/>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[220px] max-w-[calc(100vw-2rem)] max-h-[min(var(--available-height),70vh)] overflow-y-auto overscroll-contain"
>
{projectPickerOptions?.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => onProjectSelect?.(option.id)}
className="flex items-center justify-between gap-3"
title={option.projectDescription}
>
<span className="flex min-w-0 items-center gap-1.5">
<ProjectHeaderIdentity
id={option.id}
projectLabel={option.projectLabel}
projectIcon={option.projectIcon}
projectColor={option.projectColor}
projectIconImage={option.projectIconImage}
projectIconBackground={option.projectIconBackground}
/>
</span>
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={handleToggleMouseDown}
onClick={handleToggleClick}
{...listeners}
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
isCollapsed={isCollapsed}
alwaysShowActions={alwaysShowActions}
/>
{statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
)}
<div className={cn(
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
+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;
+2
View File
@@ -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.
@@ -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',
});
});
});
@@ -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<SessionDisplayStore>()(
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<SessionDisplayStore>()(
}),
{
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 };
@@ -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 () => {
+4
View File
@@ -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<SessionUIState>()((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)
@@ -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.
@@ -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);
@@ -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();
@@ -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 {