From a78ecf8a7cb6e46cc656ea32f89f2811c2075b59 Mon Sep 17 00:00:00 2001 From: Angel Davila <36370758+adavila0703@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:35:24 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../ui/src/components/layout/ContextPanel.tsx | 43 ++++++++++++ .../src/components/ui/sortable-tabs-strip.tsx | 46 +++++++++++-- packages/ui/src/lib/i18n/messages/de.ts | 5 ++ packages/ui/src/lib/i18n/messages/en.ts | 5 ++ packages/ui/src/lib/i18n/messages/es.ts | 5 ++ packages/ui/src/lib/i18n/messages/fr.ts | 5 ++ packages/ui/src/lib/i18n/messages/ja.ts | 5 ++ packages/ui/src/lib/i18n/messages/ko.ts | 5 ++ packages/ui/src/lib/i18n/messages/pl.ts | 5 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 ++ packages/ui/src/lib/i18n/messages/tr.ts | 5 ++ packages/ui/src/lib/i18n/messages/uk.ts | 5 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 ++ .../stores/useUIStore.contextPanel.test.ts | 69 +++++++++++++++++++ packages/ui/src/stores/useUIStore.ts | 54 ++++++++++----- packages/vscode/CHANGELOG.md | 1 + 18 files changed, 251 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffba71b1..dcbc2100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file. - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Multi-Run: groups can now contain more than five models, including isolated runs that create one worktree per model (thanks to @tomzx). - Files: the Markdown preview has an in-document search (Ctrl/Cmd+F) with highlighting and next/previous, and clicking a folder or file in the sidebar tree opens it reliably on macOS trackpads, where a tiny pointer move used to swallow the click (thanks to @makeittech); files up to 20,000 lines open in the full-file preview instead of being rejected at 5,000 (thanks to @gaojunran). +- Panels: right-click an editor, chat, or browser tab to close it, close others, close left/right, or close all (thanks to @adavila0703). - Plans: saved plans open with their content again for chats, worktrees outside the project path, and tabs restored after a reload, and an edit made right before closing is no longer lost. - Browser: when the agent captures a page while the browser panel is hidden, the panel is revealed first instead of the capture failing. - Sidebar: Recent rows show a compact timestamp on web and desktop, and pending permission/question badges are no longer covered by the hover actions (thanks to @makeittech). diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 7e05d80a..d4075aee 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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 ( + <> + + + {t('contextPanel.tab.menu.close')} + + + + {t('contextPanel.tab.menu.closeOthers')} + + + {t('contextPanel.tab.menu.closeToLeft')} + + + {t('contextPanel.tab.menu.closeToRight')} + + + + + {t('contextPanel.tab.menu.closeAll')} + + + ); + }, + [closeContextPanelTabs, directoryKey, t], + ); + const header = (
{isMultiInstanceMode ? ( @@ -1005,6 +1047,7 @@ export const ContextPanel: React.FC = () => { }} layoutMode="scrollable" variant="default" + tabContextMenu={renderTabContextMenu} /> ) : (
diff --git a/packages/ui/src/components/ui/sortable-tabs-strip.tsx b/packages/ui/src/components/ui/sortable-tabs-strip.tsx index e7474c59..ec506db9 100644 --- a/packages/ui/src/components/ui/sortable-tabs-strip.tsx +++ b/packages/ui/src/components/ui/sortable-tabs-strip.tsx @@ -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 = ({ animateActivePill, activePillLowercase = true, nonCompositedIndicator = false, + tabContextMenu, className, }) => { const { t } = useI18n(); @@ -445,7 +456,7 @@ export const SortableTabsStrip: React.FC = ({ 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 = ({ } } : undefined; - return ( - -
onClose?.(item.id), + }) + : null; + + const tabElement = ( +
setTabRef(item.id, element)} onAuxClick={handleAuxClick} onMouseDown={handleMouseDown} @@ -636,6 +656,24 @@ export const SortableTabsStrip: React.FC = ({ ) : null}
+ ); + + return ( + + {tabMenuItems ? ( + + ( +
+ {tabElement} +
+ )} + /> + {tabMenuItems} +
+ ) : ( + tabElement + )}
); })} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index d69c96a1..ae153393 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1115,6 +1115,11 @@ export const dict = { 'contextPanel.browser.annotate.submit': 'Anhängen', 'contextPanel.browser.trustNotice': 'Seiten, die hier geöffnet werden, laufen mit vollständigem Zugriff auf OpenChamber — erforderlich für Inspect und Screenshots. Öffnen Sie nur Seiten, denen Sie vertrauen: Eine bösartige Seite könnte Ihre Daten lesen oder in Ihrem Namen handeln.', 'contextPanel.tab.closeTabAria': '{label}-Registerkarte schließen', + 'contextPanel.tab.menu.close': 'Schließen', + 'contextPanel.tab.menu.closeOthers': 'Andere schließen', + 'contextPanel.tab.menu.closeToLeft': 'Tabs links daneben schließen', + 'contextPanel.tab.menu.closeToRight': 'Tabs rechts daneben schließen', + 'contextPanel.tab.menu.closeAll': 'Alle Tabs schließen', 'contextPanel.actions.collapsePanel': 'Panel einklappen', 'contextPanel.actions.expandPanel': 'Panel ausklappen', 'contextPanel.actions.closePanel': 'Panel schließen', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index ab611530..96d89111 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1308,6 +1308,11 @@ export const dict = { 'contextPanel.browser.annotate.submit': 'Attach', 'contextPanel.browser.trustNotice': 'Pages opened here run with full access to OpenChamber — needed for inspect and screenshots. Only open sites you trust: a malicious page could read your data or act on your behalf.', 'contextPanel.tab.closeTabAria': 'Close {label} tab', + 'contextPanel.tab.menu.close': 'Close', + 'contextPanel.tab.menu.closeOthers': 'Close others', + 'contextPanel.tab.menu.closeToLeft': 'Close tabs to the left', + 'contextPanel.tab.menu.closeToRight': 'Close tabs to the right', + 'contextPanel.tab.menu.closeAll': 'Close all tabs', 'contextPanel.actions.collapsePanel': 'Collapse panel', 'contextPanel.actions.expandPanel': 'Expand panel', 'contextPanel.actions.closePanel': 'Close panel', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a1b28e3d..9e604795 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1309,6 +1309,11 @@ export const dict: Record = { "contextPanel.browser.annotate.submit": "Adjuntar", "contextPanel.browser.trustNotice": "Las páginas que abras aquí se ejecutan con acceso completo a OpenChamber: necesario para la inspección y las capturas. Abre solo sitios de confianza: una página maliciosa podría leer tus datos o actuar en tu nombre.", "contextPanel.tab.closeTabAria": "Cerrar pestaña {label}", + "contextPanel.tab.menu.close": "Cerrar", + "contextPanel.tab.menu.closeOthers": "Cerrar otras", + "contextPanel.tab.menu.closeToLeft": "Cerrar pestañas a la izquierda", + "contextPanel.tab.menu.closeToRight": "Cerrar pestañas a la derecha", + "contextPanel.tab.menu.closeAll": "Cerrar todas las pestañas", "contextPanel.actions.collapsePanel": "Colapsar panel", "contextPanel.actions.expandPanel": "Expandir panel", "contextPanel.actions.closePanel": "Cerrar panel", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index c7e04bcd..316df20f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1075,6 +1075,11 @@ export const dict = { 'contextPanel.browser.empty': 'Navigateur Internet', 'contextPanel.browser.emptyHint': 'Entrez une adresse ci-dessus pour commencer à naviguer sur le Web', 'contextPanel.tab.closeTabAria': 'Fermer l\'onglet {label}', + 'contextPanel.tab.menu.close': 'Fermer', + 'contextPanel.tab.menu.closeOthers': 'Fermer les autres', + 'contextPanel.tab.menu.closeToLeft': 'Fermer les onglets à gauche', + 'contextPanel.tab.menu.closeToRight': 'Fermer les onglets à droite', + 'contextPanel.tab.menu.closeAll': 'Fermer tous les onglets', 'contextPanel.actions.collapsePanel': 'Réduire le panneau', 'contextPanel.actions.expandPanel': 'Agrandir le panneau', 'contextPanel.actions.closePanel': 'Fermer le panneau', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1a568cac..82af15b6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1305,6 +1305,11 @@ export const dict: Record = { 'contextPanel.browser.annotate.submit': '添付', 'contextPanel.browser.trustNotice': 'ここで開かれたページはOpenChamberへの完全なアクセス権を持ちます — 検査とスクリーンショットに必要です。信頼できるサイトのみを開いてください: 悪意のあるページがデータを読み取ったりあなたの代わりに行動したりする可能性があります。', 'contextPanel.tab.closeTabAria': '{label}タブを閉じる', + 'contextPanel.tab.menu.close': '閉じる', + 'contextPanel.tab.menu.closeOthers': '他を閉じる', + 'contextPanel.tab.menu.closeToLeft': '左のタブを閉じる', + 'contextPanel.tab.menu.closeToRight': '右のタブを閉じる', + 'contextPanel.tab.menu.closeAll': 'すべてのタブを閉じる', 'contextPanel.actions.collapsePanel': 'パネルを折りたたむ', 'contextPanel.actions.expandPanel': 'パネルを展開', 'contextPanel.actions.closePanel': 'パネルを閉じる', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 9b969ee2..32c02d9f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1358,6 +1358,11 @@ export const dict: Record = { 'chat.messageBody.actions.openPreviewAria': '미리보기 열기', 'chat.messageBody.actions.openPreview': '미리보기 열기', 'contextPanel.tab.closeTabAria': '{label} 탭 닫기', + 'contextPanel.tab.menu.close': '닫기', + 'contextPanel.tab.menu.closeOthers': '다른 탭 닫기', + 'contextPanel.tab.menu.closeToLeft': '왼쪽 탭 닫기', + 'contextPanel.tab.menu.closeToRight': '오른쪽 탭 닫기', + 'contextPanel.tab.menu.closeAll': '모든 탭 닫기', 'contextPanel.actions.collapsePanel': '접기 패널', 'contextPanel.actions.expandPanel': '펼치기 패널', 'contextPanel.actions.closePanel': '패널 닫기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d4c3014c..9e22cbb1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1696,6 +1696,11 @@ export const dict: Record = { 'contextPanel.preview.upstreamUnreachable': 'Serwer deweloperski nie odpowiada.', 'contextPanel.preview.upstreamUnreachableHint': 'Upewnij się, że serwer deweloperski nadal działa, a następnie ponów próbę.', 'contextPanel.tab.closeTabAria': 'Zamknij kartę {label}', + 'contextPanel.tab.menu.close': 'Zamknij', + 'contextPanel.tab.menu.closeOthers': 'Zamknij pozostałe', + 'contextPanel.tab.menu.closeToLeft': 'Zamknij karty po lewej', + 'contextPanel.tab.menu.closeToRight': 'Zamknij karty po prawej', + 'contextPanel.tab.menu.closeAll': 'Zamknij wszystkie karty', 'contextSidebar.actions.copied': 'Skopiowano', 'contextSidebar.actions.copy': 'Kopiuj', 'contextSidebar.actions.copyJson': 'Kopiuj JSON', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a7fe01a9..34438f28 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1309,6 +1309,11 @@ export const dict: Record = { "contextPanel.browser.annotate.submit": "Anexar", "contextPanel.browser.trustNotice": "As páginas abertas aqui são executadas com acesso total ao OpenChamber — necessário para inspeção e capturas de tela. Abra apenas sites confiáveis: uma página maliciosa pode ler seus dados ou agir em seu nome.", "contextPanel.tab.closeTabAria": "Fechar aba {label}", + "contextPanel.tab.menu.close": "Fechar", + "contextPanel.tab.menu.closeOthers": "Fechar outras", + "contextPanel.tab.menu.closeToLeft": "Fechar abas à esquerda", + "contextPanel.tab.menu.closeToRight": "Fechar abas à direita", + "contextPanel.tab.menu.closeAll": "Fechar todas as abas", "contextPanel.actions.collapsePanel": "Recolher painel", "contextPanel.actions.expandPanel": "Expandir painel", "contextPanel.actions.closePanel": "Fechar painel", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 6a9715d8..dee11891 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -1284,6 +1284,11 @@ export const dict = { 'contextPanel.browser.annotate.submit': 'Ekle', 'contextPanel.browser.trustNotice': 'Burada açılan sayfalar OpenChamber\'a tam erişimle çalışır — inceleme ve ekran görüntüleri için gereklidir. Yalnızca güvendiğiniz siteleri açın: kötü niyetli bir sayfa verilerinizi okuyabilir veya sizin adınıza hareket edebilir.', 'contextPanel.tab.closeTabAria': '{label} sekmesini kapat', + 'contextPanel.tab.menu.close': 'Kapat', + 'contextPanel.tab.menu.closeOthers': 'Diğerlerini kapat', + 'contextPanel.tab.menu.closeToLeft': 'Soldaki sekmeleri kapat', + 'contextPanel.tab.menu.closeToRight': 'Sağdaki sekmeleri kapat', + 'contextPanel.tab.menu.closeAll': 'Tüm sekmeleri kapat', 'contextPanel.actions.collapsePanel': 'Paneli daralt', 'contextPanel.actions.expandPanel': 'Paneli genişlet', 'contextPanel.actions.closePanel': 'Paneli kapat', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 616e96aa..8f384af4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1309,6 +1309,11 @@ export const dict: Record = { "contextPanel.browser.annotate.submit": "Додати", "contextPanel.browser.trustNotice": "Сторінки, відкриті тут, працюють із повним доступом до OpenChamber — це потрібно для inspect і скріншотів. Відкривайте лише сайти, яким довіряєте: шкідлива сторінка може прочитати ваші дані чи діяти від вашого імені.", "contextPanel.tab.closeTabAria": "Закрити вкладку {label}", + "contextPanel.tab.menu.close": "Закрити", + "contextPanel.tab.menu.closeOthers": "Закрити інші", + "contextPanel.tab.menu.closeToLeft": "Закрити вкладки ліворуч", + "contextPanel.tab.menu.closeToRight": "Закрити вкладки праворуч", + "contextPanel.tab.menu.closeAll": "Закрити всі вкладки", "contextPanel.actions.collapsePanel": "Згорнути панель", "contextPanel.actions.expandPanel": "Розгорнути панель", "contextPanel.actions.closePanel": "Закрити панель", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index db435419..54375afc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1309,6 +1309,11 @@ export const dict: Record = { 'contextPanel.browser.annotate.submit': '附加', 'contextPanel.browser.trustNotice': '在此打开的页面以对 OpenChamber 的完全访问权限运行 — 检查和截图需要此权限。仅打开你信任的站点:恶意页面可能读取你的数据或以你的身份执行操作。', 'contextPanel.tab.closeTabAria': '关闭 {label} 标签', + 'contextPanel.tab.menu.close': '关闭', + 'contextPanel.tab.menu.closeOthers': '关闭其他', + 'contextPanel.tab.menu.closeToLeft': '关闭左侧标签', + 'contextPanel.tab.menu.closeToRight': '关闭右侧标签', + 'contextPanel.tab.menu.closeAll': '关闭所有标签', 'contextPanel.actions.collapsePanel': '折叠面板', 'contextPanel.actions.expandPanel': '展开面板', 'contextPanel.actions.closePanel': '关闭面板', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 2aadce91..8e9b2234 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1321,6 +1321,11 @@ export const dict: Record = { 'contextPanel.browser.annotate.submit': '附加', 'contextPanel.browser.trustNotice': '在此開啟的頁面以對 OpenChamber 的完整存取權限執行 — 檢查與截圖需要此權限。僅開啟你信任的網站:惡意頁面可能讀取你的資料或以你的身分執行操作。', 'contextPanel.tab.closeTabAria': '關閉 {label} 分頁', + 'contextPanel.tab.menu.close': '關閉', + 'contextPanel.tab.menu.closeOthers': '關閉其他', + 'contextPanel.tab.menu.closeToLeft': '關閉左側分頁', + 'contextPanel.tab.menu.closeToRight': '關閉右側分頁', + 'contextPanel.tab.menu.closeAll': '關閉所有分頁', 'contextPanel.actions.collapsePanel': '摺疊面板', 'contextPanel.actions.expandPanel': '展開面板', 'contextPanel.actions.closePanel': '關閉面板', diff --git a/packages/ui/src/stores/useUIStore.contextPanel.test.ts b/packages/ui/src/stores/useUIStore.contextPanel.test.ts index 749272f8..8a08c113 100644 --- a/packages/ui/src/stores/useUIStore.contextPanel.test.ts +++ b/packages/ui/src/stores/useUIStore.contextPanel.test.ts @@ -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'; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index b23a09be..3049ccd8 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( }, 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); + } } }, diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 17e48784..4bbf2000 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -16,6 +16,7 @@ - Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install and shows OpenCode's own reason when an update is refused (thanks to @mdatsev and @yulia-ivashko). - "Add Project" now adds the chosen folder to the workspace instead of failing (thanks to @bashrusakh), and the extension starts in the current workspace folder instead of one restored from storage (thanks to @makeittech). - Multi-Run groups can now contain more than five models (thanks to @tomzx). +- Right-click an editor, chat, or browser tab in the context panel to close it, close others, close left/right, or close all (thanks to @adavila0703). - Sidebar: pending permission and question badges are no longer covered by the hover actions (thanks to @makeittech); worktree branch search hides non-matching branches (thanks to @bashrusakh). - Settings: number fields and selects no longer clip at large font sizes (thanks to @makeittech), and Windows skill paths are classified correctly, so disabled and duplicate skills are hidden as intended (thanks to @Ttungx). - Windows: closing VS Code now stops the managed OpenCode process instead of leaving it running (thanks to @a0000001).