fix(sidebar): show sessions in both Recent and Project sections (#715)

* fix(sidebar): show sessions in both Recent and Project sections

Previously, sessions displayed in the Recent section were filtered out of
their Project section (dedup logic). This meant users could only see a
session in one place, making it hard to find sessions within their project
context.

Now sessions appear in both:
- Recent section: with project name tag for quick identification
- Project section: in their normal grouped position

The project tag (secondaryMeta.projectLabel) was already implemented in
SessionNodeItem but never visible because the dedup logic removed sessions
from project sections before metadata could be populated.

Co-investigated-by: @huylamnguyen

* feat(sidebar): add collapse/expand all projects toggle

When many projects are open, collapsing them one by one is tedious. Add
'Collapse all' and 'Expand all' options to the sidebar display mode dropdown
menu (gear icon).

- Add collapseAllProjects/expandAllProjects callbacks in SessionSidebar
- Pass to SidebarHeader and render in the existing dropdown menu
- Persist collapsed state to localStorage and server settings
- Uses RiContractUpDownLine/RiExpandUpDownLine icons from Remixicon

Co-investigated-by: @huylamnguyen

* refactor: simplify session sidebar section rendering

Remove redundant filtered section aliases in SessionSidebar
Pass project and render sections directly for clearer code

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-03-20 12:35:06 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 358245d69c
commit 60a4110d7f
2 changed files with 48 additions and 49 deletions
@@ -743,6 +743,34 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
}, []);
const collapseAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
setCollapsedProjects(() => {
const allIds = new Set(projects.map((p) => p.id));
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
} catch { /* ignored */ }
if (!isVSCode) {
scheduleCollapsedProjectsPersist(allIds);
}
return allIds;
});
}, [projects, isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
const expandAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
setCollapsedProjects(() => {
const empty = new Set<string>();
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
} catch { /* ignored */ }
if (!isVSCode) {
scheduleCollapsedProjectsPersist(empty);
}
return empty;
});
}, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
const toggleProject = React.useCallback((projectId: string) => {
// Ignore intersection events for a short period after toggling
ignoreIntersectionUntil.current = Date.now() + 150;
@@ -996,53 +1024,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
];
}, [activeNowSessions, sessionSidebarMetaById]);
const activitySessionIds = React.useMemo(() => {
const next = new Set<string>();
activitySections.forEach((section) => {
section.items.forEach((item) => {
next.add(item.node.session.id);
});
});
return next;
}, [activitySections]);
const filteredProjectSections = React.useMemo(() => {
if (hasSessionSearchQuery || activitySessionIds.size === 0) {
return projectSections;
}
const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
return nodes.flatMap((node) => {
if (activitySessionIds.has(node.session.id)) {
return [];
}
return [{
...node,
children: filterNodes(node.children),
}];
});
};
return projectSections.map((section) => ({
...section,
groups: section.groups.map((group) => ({
...group,
sessions: filterNodes(group.sessions),
})),
}));
}, [hasSessionSearchQuery, activitySessionIds, projectSections]);
const filteredSectionsForRender = React.useMemo(() => {
if (hasSessionSearchQuery || activitySessionIds.size === 0) {
return sectionsForRender;
}
const sectionsByProjectId = new Map(filteredProjectSections.map((section) => [section.project.id, section]));
return sectionsForRender
.map((section) => sectionsByProjectId.get(section.project.id) ?? section)
.filter(Boolean);
}, [hasSessionSearchQuery, activitySessionIds, filteredProjectSections, sectionsForRender]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const mobileHeaderActionButtonClass =
@@ -1352,12 +1333,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSearchQuery={setSessionSearchQuery}
hasSessionSearchQuery={hasSessionSearchQuery}
searchMatchCount={searchMatchCount}
collapseAllProjects={collapseAllProjects}
expandAllProjects={expandAllProjects}
/>
<SidebarProjectsList
topContent={topContent}
sectionsForRender={filteredSectionsForRender}
projectSections={filteredProjectSections}
sectionsForRender={sectionsForRender}
projectSections={projectSections}
activeProjectId={activeProjectId}
showOnlyMainWorkspace={showOnlyMainWorkspace}
hasSessionSearchQuery={hasSessionSearchQuery}
@@ -3,6 +3,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -13,6 +14,8 @@ import {
RiFolderAddLine,
RiSearchLine,
RiCloseLine,
RiContractUpDownLine,
RiExpandUpDownLine,
} from '@remixicon/react';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -30,6 +33,8 @@ type Props = {
setSessionSearchQuery: (value: string) => void;
hasSessionSearchQuery: boolean;
searchMatchCount: number;
collapseAllProjects: () => void;
expandAllProjects: () => void;
};
export function SidebarHeader(props: Props): React.ReactNode {
@@ -47,6 +52,8 @@ export function SidebarHeader(props: Props): React.ReactNode {
setSessionSearchQuery,
hasSessionSearchQuery,
searchMatchCount,
collapseAllProjects,
expandAllProjects,
} = props;
const displayMode = useSessionDisplayStore((state) => state.displayMode);
@@ -136,6 +143,15 @@ export function SidebarHeader(props: Props): React.ReactNode {
<span>Minimal</span>
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
<RiContractUpDownLine className="h-4 w-4" />
<span>Collapse all</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
<RiExpandUpDownLine className="h-4 w-4" />
<span>Expand all</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>