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;
};
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 prevSession = prev.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.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
const prevMenuKey = `${prev.renderContext ?? 'project'}:${prev.archivedBucket ? 'archived' : 'active'}:${prevSessionId}`;
const nextMenuKey = `${next.renderContext ?? 'project'}:${next.archivedBucket ? 'archived' : 'active'}:${nextSessionId}`;
if ((prev.openSidebarMenuKey === prevMenuKey) !== (next.openSidebarMenuKey === nextMenuKey)) return false;
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false);
if (prevMenuInTree !== nextMenuInTree) return false;
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(prev.groupDirectory ?? null);
@@ -426,6 +450,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
};
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();
};
@@ -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]',
mobileVariant
? (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')}>
@@ -585,25 +621,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
)}>
{sessionCompactUpdatedLabel}
</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>
) : null}
{pendingPermissionCount > 0 ? (
@@ -683,41 +700,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</div>
) : null}
{!isMinimalMode || mobileVariant ? (
<div className={cn(
'absolute right-0 top-1/2 z-10 -translate-y-1/2',
cn(
'transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: (mobileVariant && !isVSCode)
? 'opacity-100 pointer-events-auto'
: cn('opacity-0 pointer-events-none', revealOnHoverClass),
),
)}>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'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',
isMinimalMode && !mobileVariant
? (isMenuOpen
? 'h-4 w-4 opacity-100 pointer-events-auto'
: cn('h-4 w-4 opacity-0 pointer-events-none', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
onClick={handleMenuTriggerClick}
onKeyDown={(event) => event.stopPropagation()}
>
<RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
</button>
</DropdownMenuTrigger>
{sessionMenuContent}
</DropdownMenu>
</div>
) : null}
<div className={cn(
'absolute right-0 top-1/2 z-10 -translate-y-1/2 transition-opacity',
isMenuOpen
? 'opacity-100'
: (mobileVariant && !isVSCode)
? 'opacity-100'
: cn('opacity-0', revealOnHoverClass),
)}>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'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',
isMinimalMode && !mobileVariant
? (isMenuOpen
? 'h-4 w-4 opacity-100'
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick}
onKeyDown={(event) => event.stopPropagation()}
>
<RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
</button>
</DropdownMenuTrigger>
{sessionMenuContent}
</DropdownMenu>
</div>
</div>
</DraggableSessionRow>
{hasChildren && isExpanded
@@ -118,6 +118,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
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>) => {
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
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',
)}
aria-label="Project menu"
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick}
>
<RiMore2Line className="h-3.5 w-3.5" />