fix: sidebar dropdown works properly for sub-sessions

This commit is contained in:
Bohdan Triapitsyn
2026-04-20 18:03:34 +03:00
parent 0f753fa62e
commit 1a2738a2e2
2 changed files with 82 additions and 58 deletions
@@ -130,6 +130,30 @@ const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boo
return false; return false;
}; };
const treeContainsMenuKey = (
node: SessionNode,
menuKey: string | null,
renderContext: 'project' | 'recent',
archivedBucket: boolean,
): boolean => {
if (!menuKey) {
return false;
}
const nodeMenuKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
return true;
}
for (const child of node.children) {
if (treeContainsMenuKey(child, menuKey, renderContext, archivedBucket)) {
return true;
}
}
return false;
};
const areEqual = (prev: Props, next: Props): boolean => { const areEqual = (prev: Props, next: Props): boolean => {
const prevSession = prev.node.session; const prevSession = prev.node.session;
const nextSession = next.node.session; const nextSession = next.node.session;
@@ -159,9 +183,9 @@ const areEqual = (prev: Props, next: Props): boolean => {
if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false; if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false;
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false; if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
const prevMenuKey = `${prev.renderContext ?? 'project'}:${prev.archivedBucket ? 'archived' : 'active'}:${prevSessionId}`; const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
const nextMenuKey = `${next.renderContext ?? 'project'}:${next.archivedBucket ? 'archived' : 'active'}:${nextSessionId}`; const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false);
if ((prev.openSidebarMenuKey === prevMenuKey) !== (next.openSidebarMenuKey === nextMenuKey)) return false; if (prevMenuInTree !== nextMenuInTree) return false;
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null) const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(prev.groupDirectory ?? null); ?? normalizePath(prev.groupDirectory ?? null);
@@ -426,6 +450,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}; };
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setOpenSidebarMenuKey(isMenuOpen ? null : menuInstanceKey);
};
const handleMenuTriggerPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};
const handleMenuTriggerMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}; };
@@ -568,7 +604,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', 'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
mobileVariant mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7') ? (isVSCode ? revealPaddingClass : 'pr-7')
: '', : revealPaddingClass,
)} )}
> >
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}> <div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
@@ -585,25 +621,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
)}> )}>
{sessionCompactUpdatedLabel} {sessionCompactUpdatedLabel}
</span> </span>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'absolute inset-y-0 right-0 inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: cn('opacity-0 pointer-events-none', revealOnHoverClass),
)}
aria-label="Session menu"
onClick={handleMenuTriggerClick}
onKeyDown={(event) => event.stopPropagation()}
>
<RiMore2Line className="h-2.5 w-2.5" />
</button>
</DropdownMenuTrigger>
{sessionMenuContent}
</DropdownMenu>
</div> </div>
) : null} ) : null}
{pendingPermissionCount > 0 ? ( {pendingPermissionCount > 0 ? (
@@ -683,41 +700,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</div> </div>
) : null} ) : null}
{!isMinimalMode || mobileVariant ? ( <div className={cn(
<div className={cn( 'absolute right-0 top-1/2 z-10 -translate-y-1/2 transition-opacity',
'absolute right-0 top-1/2 z-10 -translate-y-1/2', isMenuOpen
cn( ? 'opacity-100'
'transition-opacity', : (mobileVariant && !isVSCode)
isMenuOpen ? 'opacity-100'
? 'opacity-100 pointer-events-auto' : cn('opacity-0', revealOnHoverClass),
: (mobileVariant && !isVSCode) )}>
? 'opacity-100 pointer-events-auto' <DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
: cn('opacity-0 pointer-events-none', revealOnHoverClass), <DropdownMenuTrigger asChild>
), <button
)}> type="button"
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}> className={cn(
<DropdownMenuTrigger asChild> 'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
<button isMinimalMode && !mobileVariant
type="button" ? (isMenuOpen
className={cn( ? 'h-4 w-4 opacity-100'
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity', : cn('h-4 w-4 opacity-0', revealOnHoverClass))
isMinimalMode && !mobileVariant : 'h-6 w-6 opacity-100',
? (isMenuOpen )}
? 'h-4 w-4 opacity-100 pointer-events-auto' aria-label="Session menu"
: cn('h-4 w-4 opacity-0 pointer-events-none', revealOnHoverClass)) onPointerDown={handleMenuTriggerPointerDown}
: 'h-6 w-6 opacity-100', onMouseDown={handleMenuTriggerMouseDown}
)} onClick={handleMenuTriggerClick}
aria-label="Session menu" onKeyDown={(event) => event.stopPropagation()}
onClick={handleMenuTriggerClick} >
onKeyDown={(event) => event.stopPropagation()} <RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
> </button>
<RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} /> </DropdownMenuTrigger>
</button> {sessionMenuContent}
</DropdownMenuTrigger> </DropdownMenu>
{sessionMenuContent} </div>
</DropdownMenu>
</div>
) : null}
</div> </div>
</DraggableSessionRow> </DraggableSessionRow>
{hasChildren && isExpanded {hasChildren && isExpanded
@@ -118,6 +118,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
event.stopPropagation(); event.stopPropagation();
}, []); }, []);
const handleMenuTriggerPointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
const handleMenuTriggerMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
const handleToggleMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { const handleToggleMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) { if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
suppressNextToggleRef.current = true; suppressNextToggleRef.current = true;
@@ -250,6 +258,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
: 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto', : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)} )}
aria-label="Project menu" aria-label="Project menu"
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick} onClick={handleMenuTriggerClick}
> >
<RiMore2Line className="h-3.5 w-3.5" /> <RiMore2Line className="h-3.5 w-3.5" />