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
@@ -319,6 +319,75 @@ describe('useUIStore closeContextPanelTab surface stability', () => {
});
});
describe('useUIStore closeContextPanelTabs bulk', () => {
const directory = '/repo';
test('closing every tab of the only surface closes the panel', () => {
useUIStore.getState().openContextBrowser(directory, 'https://a.test');
useUIStore.getState().openContextBrowser(directory, 'https://b.test');
useUIStore.getState().openContextBrowser(directory, 'https://c.test');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const ids = state0?.tabs.map((tab) => tab.id) ?? [];
useUIStore.getState().closeContextPanelTabs(directory, ids);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs).toHaveLength(0);
expect(state?.isOpen).toBe(false);
});
test('closing all tabs of the active surface closes the panel but keeps other surfaces in state', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileIds = state0?.tabs.filter((tab) => tab.mode === 'file').map((tab) => tab.id) ?? [];
useUIStore.getState().closeContextPanelTabs(directory, fileIds);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.tabs.map((tab) => tab.mode)).toEqual(['terminal']);
expect(state?.activeTabId).toBe('terminal');
// Matches the single-close rule: emptying the active surface closes the panel.
expect(state?.isOpen).toBe(false);
});
test('closing only inactive-mode tabs leaves the active tab and panel intact', () => {
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTab = state0?.tabs.find((tab) => tab.mode === 'file');
useUIStore.getState().closeContextPanelTabs(directory, [fileTab?.id as string]);
const state = useUIStore.getState().contextPanelByDirectory[directory];
expect(state?.activeTabId).toBe('terminal');
expect(state?.isOpen).toBe(true);
});
test('closing a subset of the active surface including the active tab keeps a remaining same-mode tab', () => {
useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' });
useUIStore.getState().openContextFile(directory, '/repo/a.ts');
useUIStore.getState().openContextFile(directory, '/repo/b.ts');
useUIStore.getState().openContextFile(directory, '/repo/c.ts');
const state0 = useUIStore.getState().contextPanelByDirectory[directory];
const fileTabs = state0?.tabs.filter((tab) => tab.mode === 'file') ?? [];
const keptFile = fileTabs.find((tab) => tab.targetPath === '/repo/a.ts');
const closedIds = fileTabs.filter((tab) => tab.id !== keptFile?.id).map((tab) => tab.id);
expect(state0?.tabs.find((tab) => tab.id === state0.activeTabId)?.targetPath).toBe('/repo/c.ts');
useUIStore.getState().closeContextPanelTabs(directory, closedIds);
const state = useUIStore.getState().contextPanelByDirectory[directory];
const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId);
expect(activeTab?.mode).toBe('file');
expect(activeTab?.targetPath).toBe('/repo/a.ts');
expect(state?.isOpen).toBe(true);
expect(state?.tabs.some((tab) => tab.mode === 'terminal')).toBe(true);
});
});
describe('useUIStore per-surface panel widths', () => {
const directory = '/repo';
+35 -19
View File
@@ -482,14 +482,19 @@ const upsertContextPanelTab = (
};
};
const closeContextPanelTab = (
const closeContextPanelTabs = (
current: ContextPanelDirectoryState,
tabID: string,
tabIds: readonly string[],
): ContextPanelDirectoryState => {
const closedTab = current.tabs.find((tab) => tab.id === tabID) ?? null;
const nextTabs = current.tabs.filter((tab) => tab.id !== tabID);
const closed = new Set(tabIds);
const closedTabs = current.tabs.filter((tab) => closed.has(tab.id));
const nextTabs = current.tabs.filter((tab) => !closed.has(tab.id));
if (nextTabs.length === current.tabs.length) {
return current;
}
if (current.activeTabId !== tabID) {
const activeClosed = current.activeTabId ? closed.has(current.activeTabId) : false;
if (!activeClosed) {
return {
...current,
tabs: nextTabs,
@@ -499,10 +504,11 @@ const closeContextPanelTab = (
};
}
// Closing the active tab stays inside the active surface: activate the most
// recent remaining tab of the same mode, and when it was the last one just
// close the panel instead of jumping to another surface.
const sameModeTabs = closedTab ? nextTabs.filter((tab) => tab.mode === closedTab.mode) : [];
// Closing the active tab stays inside its surface: activate the most recent
// remaining tab of the same mode, and when none remain just close the panel
// instead of jumping to another surface.
const activeMode = closedTabs.find((tab) => tab.id === current.activeTabId)?.mode ?? null;
const sameModeTabs = activeMode ? nextTabs.filter((tab) => tab.mode === activeMode) : [];
const nextSameModeTab = sameModeTabs.length > 0
? sameModeTabs.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best))
: null;
@@ -875,6 +881,7 @@ interface UIStore {
setActiveContextPanelTab: (directory: string, tabID: string) => void;
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
closeContextPanelTab: (directory: string, tabID: string) => void;
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
@@ -1493,34 +1500,43 @@ export const useUIStore = create<UIStore>()(
},
closeContextPanelTab: (directory, tabID) => {
get().closeContextPanelTabs(directory, [tabID]);
},
closeContextPanelTabs: (directory, tabIds) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedTabID = (tabID || '').trim();
if (!normalizedDirectory || !normalizedTabID) {
const normalizedTabIds = (tabIds ?? [])
.map((id) => (id || '').trim())
.filter((id) => id.length > 0);
if (!normalizedDirectory || normalizedTabIds.length === 0) {
return;
}
const closingTab = get().contextPanelByDirectory[normalizedDirectory]?.tabs
.find((tab) => tab.id === normalizedTabID);
const closedTabs = normalizedTabIds
.map((id) => get().contextPanelByDirectory[normalizedDirectory]?.tabs.find((tab) => tab.id === id))
.filter((tab): tab is ContextPanelTab => Boolean(tab));
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
if (!current.tabs.some((tab) => normalizedTabIds.includes(tab.id))) {
return state;
}
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: closeContextPanelTab(current, normalizedTabID),
[normalizedDirectory]: closeContextPanelTabs(current, normalizedTabIds),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
});
// Keep the editor's own open-file state in sync so a reopened
// editor surface does not resurrect the closed file.
if (closingTab?.mode === 'file' && closingTab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, closingTab.targetPath);
// Keep the editor's own open-file state in sync so closed files do not
// resurrect when the editor surface reopens.
for (const tab of closedTabs) {
if (tab.mode === 'file' && tab.targetPath) {
useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, tab.targetPath);
}
}
},