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',