feat(ui): add right-click close menu for context panel tabs (#3217)

Resolves openchamber/openchamber#3123.

The right panel's tab strip (browser, files, chat, and other
multi-instance surfaces) now supports a right-click context menu with
Close, Close others, Close to the left, Close to the right, and Close
all. These act on the current surface's tabs and reuse the new bulk
close action, so closing the active surface's last tab still closes the
panel while other surfaces remain.

- Add closeContextPanelTabs(directory, ids) to useUIStore
- Add opt-in tabContextMenu prop to SortableTabsStrip (no impact on other consumers)
- Wire the menu in ContextPanel with full i18n coverage across 11 locales
- Cover the bulk close with store tests
This commit is contained in:
Angel Davila
2026-08-29 00:35:24 +03:00
committed by GitHub
parent cbe908009b
commit a78ecf8a7c
18 changed files with 251 additions and 23 deletions
@@ -3,6 +3,7 @@ import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { DiffViewIcon } from '@/components/icons/DiffIcon';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { PullRequestView } from '@/components/views/PullRequestView';
import { TerminalView } from '@/components/views/TerminalView';
@@ -979,6 +980,47 @@ export const ContextPanel: React.FC = () => {
const isFileTabActive = activeTab?.mode === 'file';
const closeContextPanelTabs = useUIStore((state) => state.closeContextPanelTabs);
const renderTabContextMenu = React.useCallback(
(args: { id: string; index: number; allIds: string[]; close: () => void }): React.ReactNode => {
if (!directoryKey) {
return null;
}
const { id, index, allIds, close } = args;
const closeOthers = () => closeContextPanelTabs(directoryKey, allIds.filter((tabId) => tabId !== id));
const closeToLeft = () => closeContextPanelTabs(directoryKey, allIds.slice(0, index));
const closeToRight = () => closeContextPanelTabs(directoryKey, allIds.slice(index + 1));
const closeAll = () => closeContextPanelTabs(directoryKey, allIds);
const hasOthers = allIds.length > 1;
const isFirst = index === 0;
const isLast = index === allIds.length - 1;
return (
<>
<ContextMenuItem onClick={close}>
<Icon name="close" className="mr-2 size-4" />
{t('contextPanel.tab.menu.close')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={closeOthers} disabled={!hasOthers}>
{t('contextPanel.tab.menu.closeOthers')}
</ContextMenuItem>
<ContextMenuItem onClick={closeToLeft} disabled={isFirst}>
{t('contextPanel.tab.menu.closeToLeft')}
</ContextMenuItem>
<ContextMenuItem onClick={closeToRight} disabled={isLast}>
{t('contextPanel.tab.menu.closeToRight')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={closeAll} disabled={!hasOthers}>
<Icon name="close-circle" className="mr-2 size-4" />
{t('contextPanel.tab.menu.closeAll')}
</ContextMenuItem>
</>
);
},
[closeContextPanelTabs, directoryKey, t],
);
const header = (
<header className="flex h-10 items-stretch border-b border-border">
{isMultiInstanceMode ? (
@@ -1005,6 +1047,7 @@ export const ContextPanel: React.FC = () => {
}}
layoutMode="scrollable"
variant="default"
tabContextMenu={renderTabContextMenu}
/>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1.5 px-3">
@@ -21,6 +21,7 @@ import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { Icon } from "@/components/icon/Icon";
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
export type SortableTabsStripItem = {
id: string;
@@ -49,6 +50,15 @@ type SortableTabsStripProps = {
(e.g. a sliding mobile drawer): creating a composited layer mid-slide
flickers in WKWebView. Tab-switch animation stays (layout transition). */
nonCompositedIndicator?: boolean;
/** Per-tab right-click context menu. Return the menu items for the given tab,
or null/undefined to disable the context menu for that tab. */
tabContextMenu?: (args: {
id: string;
index: number;
isActive: boolean;
allIds: string[];
close: () => void;
}) => React.ReactNode;
className?: string;
};
@@ -106,6 +116,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
animateActivePill,
activePillLowercase = true,
nonCompositedIndicator = false,
tabContextMenu,
className,
}) => {
const { t } = useI18n();
@@ -445,7 +456,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
aria-hidden
/>
) : null}
{items.map((item) => {
{items.map((item, index) => {
const isActive = item.id === activeId;
const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon);
const shouldShowLabel = !showInactiveIconOnly;
@@ -479,9 +490,18 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
}
}
: undefined;
return (
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
<div
const tabMenuItems = !isMobile && tabContextMenu
? tabContextMenu({
id: item.id,
index,
isActive,
allIds: itemIDs,
close: () => onClose?.(item.id),
})
: null;
const tabElement = (
<div
ref={(element) => setTabRef(item.id, element)}
onAuxClick={handleAuxClick}
onMouseDown={handleMouseDown}
@@ -636,6 +656,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
</button>
) : null}
</div>
);
return (
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
{tabMenuItems ? (
<ContextMenu>
<ContextMenuTrigger
render={(triggerProps) => (
<div {...triggerProps} className={cn('flex h-full min-w-0', triggerProps.className)}>
{tabElement}
</div>
)}
/>
<ContextMenuContent className="w-52">{tabMenuItems}</ContextMenuContent>
</ContextMenu>
) : (
tabElement
)}
</Wrapper>
);
})}