feat: add right-click menus across sidebar rows

Open row actions at the pointer position
Keep three-dot menus working separately
Reuse shared menu styling for consistent visuals
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 15:12:10 +03:00
parent 7a18e79bdb
commit 12c8cbf544
15 changed files with 774 additions and 500 deletions
@@ -1,5 +1,6 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { ContextMenu } from '@base-ui/react/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,6 +11,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
@@ -352,6 +354,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false);
@@ -602,6 +606,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
: null;
const handleMenuOpenChange = (open: boolean) => {
if (open) {
setIsContextMenuOpen(false);
}
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
};
@@ -614,6 +621,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
};
const handleContextMenuOpenChange = (open: boolean) => {
setIsContextMenuOpen(open);
};
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -706,9 +717,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
};
const sessionMenuContent = (
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
<DropdownMenuItem
const renderSessionMenuItems = ({
Item,
Separator,
Sub,
SubTrigger,
SubContent,
}: {
Item: React.ElementType;
Separator: React.ElementType;
Sub: React.ElementType;
SubTrigger: React.ElementType;
SubContent: React.ElementType;
}) => (
<>
<Item
onClick={() => {
// Defer rename until dropdown close transition completes.
// onOpenChangeComplete fires after animation + focus cleanup are done,
@@ -719,38 +742,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
>
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
</Item>
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
</DropdownMenuItem>
</Item>
{!resolvedSession.share ? (
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
<Item onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
<Icon name="share-2" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.share')}
</DropdownMenuItem>
</Item>
) : (
<>
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
<Item onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
{copiedSessionId === session.id
? <><Icon name="check" className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }}/>{t('sessions.sidebar.session.menu.copied')}</>
: <><Icon name="file-copy" className="mr-1 h-4 w-4" />{t('sessions.sidebar.session.menu.copyLink')}</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
</Item>
<Item onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
<Icon name="link-unlink-m" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.unshare')}
</DropdownMenuItem>
</Item>
</>
)}
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<Item onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
<Icon name="download" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.exportMarkdown')}
</DropdownMenuItem>
</Item>
{isMultiRunLikeSession ? (
<DropdownMenuItem onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
<FusionIcon className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.runFusion')}
</DropdownMenuItem>
</Item>
) : null}
{sessionDirectory && !archivedBucket ? (() => {
@@ -758,39 +781,39 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<Separator />
<Sub>
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
<SubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</DropdownMenuItem>
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
<Item key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
<span className="flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
</DropdownMenuItem>
</Item>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<Separator />
<Item onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
<Icon name="add" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.folders.newFolderEllipsis')}
</DropdownMenuItem>
</Item>
{currentFolderId ? (
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<Item onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
<Icon name="close" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.folders.removeFromFolder')}
</DropdownMenuItem>
</Item>
) : null}
</DropdownMenuSubContent>
</DropdownMenuSub>
</SubContent>
</Sub>
</>
);
})() : null}
{!isVSCode ? (
<DropdownMenuItem
<Item
disabled={!sessionDirectory}
onClick={() => {
if (!sessionDirectory) return;
@@ -805,42 +828,108 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="chat-4" className="mr-1 h-4 w-4" />
<span className="truncate">{t('sessions.sidebar.session.menu.openInSidePanel')}</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">{t('sessions.sidebar.session.menu.betaBadge')}</span>
</DropdownMenuItem>
</Item>
) : null}
{isElectron ? (
<DropdownMenuItem
<Item
disabled={!sessionDirectory}
onClick={handleOpenMiniChatWindow}
className="[&>svg]:mr-1"
>
<Icon name="window" className="mr-1 h-4 w-4" />
<span className="truncate">{t('sessions.sidebar.session.menu.openMiniChatWindow')}</span>
</DropdownMenuItem>
</Item>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<Separator />
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
<Icon name={archivedBucket ? "delete-bin" : "archive"} className="mr-1 h-4 w-4" />
{archivedBucket ? t('sessions.sidebar.bulkActions.delete') : t('sessions.sidebar.bulkActions.archive')}
</DropdownMenuItem>
</Item>
</>
);
const sessionMenuContent = (
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
{renderSessionMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
})}
</DropdownMenuContent>
);
const contextMenuContent = (
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-content"
finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
}}
className={cn(dropdownMenuPopupClass, 'min-w-[180px]')}
>
{renderSessionMenuItems({
Item: ({ className, ...itemProps }: React.ComponentProps<typeof ContextMenu.Item>) => (
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...itemProps} />
),
Separator: ({ className, ...separatorProps }: React.ComponentProps<typeof ContextMenu.Separator>) => (
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...separatorProps} />
),
Sub: ContextMenu.SubmenuRoot,
SubTrigger: ({ className, children, ...triggerProps }: React.ComponentProps<typeof ContextMenu.SubmenuTrigger>) => (
<ContextMenu.SubmenuTrigger className={cn(dropdownMenuSubTriggerClass, className)} {...triggerProps}>
{children}
<Icon name="arrow-right-s" className="ml-auto size-3.5" />
</ContextMenu.SubmenuTrigger>
),
SubContent: ({ className, children, ...popupProps }: React.ComponentProps<typeof ContextMenu.Popup>) => (
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-sub-content"
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
}}
className={cn(dropdownMenuPopupClass, className)}
{...popupProps}
>
{children}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
),
})}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
);
return (
<React.Fragment key={session.id}>
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
<div
data-session-row={session.id}
data-session-scope={sessionDirectory ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
className={cn(
'group relative my-0.5 flex items-center rounded-sm px-1.5 py-1',
isMissingDirectory ? 'opacity-75' : '',
depth > 0 && 'pl-[20px]',
isRowSelected && 'bg-primary/15',
)}
>
<ContextMenu.Root open={isContextMenuOpen} onOpenChange={handleContextMenuOpenChange} onOpenChangeComplete={handleMenuOpenChangeComplete}>
<ContextMenu.Trigger
render={
<div
data-session-row={session.id}
data-session-scope={sessionDirectory ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
className={cn(
'group relative my-0.5 flex items-center rounded-sm px-1.5 py-1',
isMissingDirectory ? 'opacity-75' : '',
depth > 0 && 'pl-[20px]',
isRowSelected && 'bg-primary/15',
)}
/>
}
>
{leadingIndicators}
{subsessionChevron}
<div className="flex min-w-0 flex-1 items-center">
@@ -874,7 +963,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
isMenuOpen
isSessionMenuOpen
? 'opacity-0'
: hideOnHoverClass,
)}>
@@ -963,7 +1052,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<div className={cn(
'absolute right-0 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5 transition-opacity',
isMenuOpen
isSessionMenuOpen
? 'opacity-100'
: (alwaysShowActions && !isVSCode)
? 'opacity-100'
@@ -1022,7 +1111,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
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 && !alwaysShowActions
? (isMenuOpen
? (isSessionMenuOpen
? 'h-4 w-4 opacity-100'
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
@@ -1039,7 +1128,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
{sessionMenuContent}
</DropdownMenu>
</div>
</div>
</ContextMenu.Trigger>
{contextMenuContent}
</ContextMenu.Root>
</DraggableSessionRow>
{hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
@@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
@@ -89,14 +90,35 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
const suppressNextToggleRef = React.useRef(false);
const menuInstanceKey = `project:${id}`;
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
const handleMenuOpenChange = React.useCallback((open: boolean) => {
if (open) setIsContextMenuOpen(false);
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
}, [menuInstanceKey, setOpenSidebarMenuKey]);
const renderProjectMenuItems = (Item: React.ElementType) => (
<>
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<Item onClick={onNewSession}>
<Icon name="add" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.newSession')}
</Item>
)}
<Item onClick={onRenameStart}>
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</Item>
<Item onClick={onClose} className="text-destructive focus:text-destructive">
<Icon name="close" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.closeProject')}
</Item>
</>
);
const handleMenuTriggerClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
@@ -140,12 +162,19 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
/>
)}
<div
className={cn(
'w-full text-left group/project select-none',
)}
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
>
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger
render={
<div
className={cn('w-full text-left group/project select-none')}
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
onContextMenu={(event) => {
event.preventDefault();
setIsContextMenuOpen(true);
}}
/>
}
>
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
<Tooltip>
<TooltipTrigger asChild>
@@ -261,23 +290,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<DropdownMenuItem onClick={onNewSession}>
<Icon name="add" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.newSession')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onRenameStart}>
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Icon name="close" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.closeProject')}
</DropdownMenuItem>
{renderProjectMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -312,7 +325,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</div>
) : null}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{renderProjectMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
</>
) : null}