* 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>
205 lines
8.1 KiB
TypeScript
205 lines
8.1 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import {
|
|
RiCheckLine,
|
|
RiChatNewLine,
|
|
RiEqualizer2Line,
|
|
RiFolderAddLine,
|
|
RiSearchLine,
|
|
RiCloseLine,
|
|
RiContractUpDownLine,
|
|
RiExpandUpDownLine,
|
|
} from '@remixicon/react';
|
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
|
|
|
type Props = {
|
|
hideDirectoryControls: boolean;
|
|
handleOpenDirectoryDialog: () => void;
|
|
handleNewSession: () => void;
|
|
headerActionIconClass: string;
|
|
reserveHeaderActionsSpace: boolean;
|
|
headerActionButtonClass: string;
|
|
isSessionSearchOpen: boolean;
|
|
setIsSessionSearchOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
|
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
|
sessionSearchQuery: string;
|
|
setSessionSearchQuery: (value: string) => void;
|
|
hasSessionSearchQuery: boolean;
|
|
searchMatchCount: number;
|
|
collapseAllProjects: () => void;
|
|
expandAllProjects: () => void;
|
|
};
|
|
|
|
export function SidebarHeader(props: Props): React.ReactNode {
|
|
const {
|
|
hideDirectoryControls,
|
|
handleOpenDirectoryDialog,
|
|
handleNewSession,
|
|
headerActionIconClass,
|
|
reserveHeaderActionsSpace,
|
|
headerActionButtonClass,
|
|
isSessionSearchOpen,
|
|
setIsSessionSearchOpen,
|
|
sessionSearchInputRef,
|
|
sessionSearchQuery,
|
|
setSessionSearchQuery,
|
|
hasSessionSearchQuery,
|
|
searchMatchCount,
|
|
collapseAllProjects,
|
|
expandAllProjects,
|
|
} = props;
|
|
|
|
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
|
const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode);
|
|
|
|
if (hideDirectoryControls) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="select-none flex-shrink-0 px-2.5 py-1">
|
|
{reserveHeaderActionsSpace ? (
|
|
<div className="flex h-auto min-h-8 flex-col gap-1">
|
|
<div className="flex h-8 items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenDirectoryDialog}
|
|
className={headerActionButtonClass}
|
|
aria-label="Add project"
|
|
>
|
|
<RiFolderAddLine className={headerActionIconClass} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" sideOffset={4}><p>Add project</p></TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={handleNewSession}
|
|
className={headerActionButtonClass}
|
|
aria-label="New session"
|
|
>
|
|
<RiChatNewLine className={headerActionIconClass} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
|
className={headerActionButtonClass}
|
|
aria-label="Search sessions"
|
|
aria-expanded={isSessionSearchOpen}
|
|
>
|
|
<RiSearchLine className={headerActionIconClass} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
|
|
</Tooltip>
|
|
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={headerActionButtonClass}
|
|
aria-label="Session display mode"
|
|
>
|
|
<RiEqualizer2Line className={headerActionIconClass} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end" className="min-w-[160px]">
|
|
<DropdownMenuItem
|
|
onClick={() => setDisplayMode('default')}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<span>Default</span>
|
|
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setDisplayMode('minimal')}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
{isSessionSearchOpen ? (
|
|
<div className="pb-1">
|
|
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
|
|
{hasSessionSearchQuery ? (
|
|
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
|
|
) : <span />}
|
|
<span>Esc to clear</span>
|
|
</div>
|
|
<div className="relative">
|
|
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
ref={sessionSearchInputRef}
|
|
value={sessionSearchQuery}
|
|
onChange={(event) => setSessionSearchQuery(event.target.value)}
|
|
placeholder="Search sessions..."
|
|
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
event.stopPropagation();
|
|
if (hasSessionSearchQuery) {
|
|
setSessionSearchQuery('');
|
|
} else {
|
|
setIsSessionSearchOpen(false);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{sessionSearchQuery.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSessionSearchQuery('')}
|
|
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
aria-label="Clear search"
|
|
>
|
|
<RiCloseLine className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|