From 2ab6fddb256d0ea7b5b7803dff860738768fb4b8 Mon Sep 17 00:00:00 2001 From: yoziv <59967231+yoziv@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:52:21 +0300 Subject: [PATCH] feat: confirm before closing a tab/pane session Add an opt-in "Confirm before closing" setting so an accidental tab-X click or middle-click no longer kills a live terminal/AI session. - New confirmOnCloseSession setting (default off) in terminal-store, persisted via config and toggled from Settings > Tabs. - closeTerminal gains an opts.skipConfirm; when the setting is on it shows the existing tmax confirmDialog (danger) naming the session. - New closeTerminals(ids) so bulk closes (Close All / Close Others / close group / multi-select) show one aggregate confirm instead of one dialog per tab. Covers X-click, middle-click, context-menu close, Ctrl+Shift+W, and command palette since they all route through closeTerminal. Backlog: TASK-276 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 928d2772-f9cd-42d4-864a-24d999bd1328 --- ...nfirm-before-closing-a-tab-pane-session.md | 31 +++++++++++ src/renderer/components/Settings.tsx | 7 +++ src/renderer/components/TabBar.tsx | 2 +- src/renderer/components/TabContextMenu.tsx | 6 +-- src/renderer/hooks/useKeybindings.ts | 2 +- src/renderer/state/terminal-store.ts | 54 ++++++++++++++++++- 6 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 backlog/tasks/task-276 - Confirm-before-closing-a-tab-pane-session.md diff --git a/backlog/tasks/task-276 - Confirm-before-closing-a-tab-pane-session.md b/backlog/tasks/task-276 - Confirm-before-closing-a-tab-pane-session.md new file mode 100644 index 00000000..1d707173 --- /dev/null +++ b/backlog/tasks/task-276 - Confirm-before-closing-a-tab-pane-session.md @@ -0,0 +1,31 @@ +--- +id: TASK-276 +title: Confirm before closing a tab/pane session +status: In Progress +assignee: + - yoziv +created_date: '2026-07-13 16:18' +updated_date: '2026-07-13 16:20' +labels: [] +dependencies: [] +--- + +## Description + + +User accidentally clicks the tab close X (or middle-clicks) when reaching for a tab to return to it, losing a live terminal/AI session. Add an opt-in confirmation dialog before closing a tab/pane, reusing the existing tmax-styled confirmDialog (AppDialog.tsx, TASK-115). Bulk closes (Close All/Others/group) get a single aggregate confirm, not one per tab. + + +## Acceptance Criteria + +- [ ] #1 A new opt-in setting (default off) gates confirm-before-close; when off, closing is unchanged +- [ ] #2 With the setting on, clicking the tab X or middle-clicking a tab shows a confirm dialog naming the session; Cancel keeps the session, Close removes it +- [ ] #3 Bulk closes (Close All / Close Others / close group / multi-select close) show a single aggregate confirm, never one dialog per tab +- [ ] #4 Setting persists across restarts and has a toggle in Settings > Tabs + + +## Implementation Plan + + +1. Add opt-in setting confirmOnCloseSession (default false) in terminal-store: state field, default value, config load, and toggleConfirmOnCloseSession action - mirroring hideTabCloseButtons. + diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 585245d0..33daf516 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -775,6 +775,13 @@ const AppearanceSettings: React.FC = () => { + + +
= ({ ver .filter(([, t]) => t.groupId === groupMenu.groupId) .map(([id]) => id); store.deleteTabGroup(groupMenu.groupId); - (async () => { for (const id of ids) await store.closeTerminal(id); })(); + store.closeTerminals(ids); setGroupMenu(null); }}> Close All diff --git a/src/renderer/components/TabContextMenu.tsx b/src/renderer/components/TabContextMenu.tsx index c91fc916..4cf08fba 100644 --- a/src/renderer/components/TabContextMenu.tsx +++ b/src/renderer/components/TabContextMenu.tsx @@ -447,21 +447,21 @@ const TabContextMenu: React.FC = ({ position, selectedAtOpe : [position.terminalId]; onClose(); useTerminalStore.getState().clearSelection(); - (async () => { for (const id of ids) await useTerminalStore.getState().closeTerminal(id); })(); + useTerminalStore.getState().closeTerminals(ids); }}> Close{targetIds.length > 1 ? ` (${targetIds.length})` : ''} {formatKeyForPlatform('Ctrl+Shift+W')} diff --git a/src/renderer/hooks/useKeybindings.ts b/src/renderer/hooks/useKeybindings.ts index 2fbf6672..77fec7f2 100644 --- a/src/renderer/hooks/useKeybindings.ts +++ b/src/renderer/hooks/useKeybindings.ts @@ -238,7 +238,7 @@ function dispatchAction(action: string): void { : (focusedId ? [focusedId] : []); if (ids.length === 0) break; if (sel.length > 0) store.clearSelection(); - (async () => { for (const id of ids) await store.closeTerminal(id); })(); + store.closeTerminals(ids); break; } case 'restoreClosedTerminal': diff --git a/src/renderer/state/terminal-store.ts b/src/renderer/state/terminal-store.ts index f7359604..672efce8 100644 --- a/src/renderer/state/terminal-store.ts +++ b/src/renderer/state/terminal-store.ts @@ -897,6 +897,10 @@ interface TerminalStore { tabBarPosition: 'top' | 'bottom' | 'left' | 'right'; hideTabTitles: boolean; hideTabCloseButtons: boolean; + // When true, closing a tab/pane asks for confirmation first so an + // accidental X-click or middle-click doesn't kill a live session. Bulk + // closes collapse to a single aggregate confirm. Opt-in; default off. + confirmOnCloseSession: boolean; renamingTerminalId: TerminalId | null; viewMode: 'split' | 'focus' | 'grid'; broadcastMode: boolean; // when true, typing in any pane is sent to all tiled panes @@ -988,7 +992,10 @@ interface TerminalStore { // Actions loadConfig: () => Promise; createTerminal: (shellProfileId?: string, cwdOverride?: string) => Promise; - closeTerminal: (id: TerminalId) => Promise; + closeTerminal: (id: TerminalId, opts?: { skipConfirm?: boolean }) => Promise; + // Close many tabs at once with a single aggregate confirm (instead of + // one dialog per tab). Used by Close All / Close Others / close group. + closeTerminals: (ids: TerminalId[]) => Promise; replaceTerminal: (id: TerminalId, shellProfileId?: string) => Promise; /** * Browser-style undo close (TASK-112). Pops the most recent entry off @@ -1034,6 +1041,7 @@ interface TerminalStore { toggleTabBarPosition: () => void; toggleHideTabTitles: () => void; toggleHideTabCloseButtons: () => void; + toggleConfirmOnCloseSession: () => void; setTerminalOpacity: (opacity: number) => void; startRenaming: (id: TerminalId | null) => void; toggleViewMode: () => void; @@ -1376,6 +1384,7 @@ export const useTerminalStore = create((set, get) => ({ tabBarPosition: 'top' as 'top' | 'bottom' | 'left' | 'right', hideTabTitles: false, hideTabCloseButtons: false, + confirmOnCloseSession: false, renamingTerminalId: null, viewMode: 'grid' as 'split' | 'focus' | 'grid', broadcastMode: false, @@ -1399,6 +1408,7 @@ export const useTerminalStore = create((set, get) => ({ if (config?.tabBarPosition) updates.tabBarPosition = config.tabBarPosition; if (typeof (config as any)?.hideTabTitles === 'boolean') updates.hideTabTitles = (config as any).hideTabTitles; if (typeof (config as any)?.hideTabCloseButtons === 'boolean') updates.hideTabCloseButtons = (config as any).hideTabCloseButtons; + if (typeof (config as any)?.confirmOnCloseSession === 'boolean') updates.confirmOnCloseSession = (config as any).confirmOnCloseSession; if ((config as any)?.terminalOpacity != null) { updates.terminalOpacity = (config as any).terminalOpacity; document.documentElement.style.setProperty('--terminal-opacity', String((config as any).terminalOpacity)); @@ -1613,12 +1623,26 @@ export const useTerminalStore = create((set, get) => ({ get().saveSession(); }, - closeTerminal: async (id: TerminalId) => { + closeTerminal: async (id: TerminalId, opts?: { skipConfirm?: boolean }) => { const t0 = performance.now(); const { terminals, layout, focusedTerminalId, closedTerminals, copilotSessions, claudeCodeSessions } = get(); const instance = terminals.get(id); if (!instance) return; + // Guard against an accidental X-click / middle-click killing a live + // session. skipConfirm lets bulk closers show one aggregate confirm + // instead of one dialog per pane (see closeTerminals). + if (get().confirmOnCloseSession && !opts?.skipConfirm) { + const label = instance.title?.trim() || 'this session'; + const ok = await confirmDialog({ + title: 'Close session?', + message: `Close "${label}"? This ends the terminal session.`, + confirmText: 'Close', + danger: true, + }); + if (!ok) return; + } + // Snapshot pane identity for browser-style undo close (TASK-112). // Capture BEFORE the PTY is killed so the metadata is intact even if // killPty surfaces an error. Cap at 10 - older entries fall off the @@ -1722,6 +1746,26 @@ export const useTerminalStore = create((set, get) => ({ }); }, + closeTerminals: async (ids: TerminalId[]) => { + if (ids.length === 0) return; + // A single tab still names itself; let closeTerminal own that confirm. + if (ids.length === 1) { + await get().closeTerminal(ids[0]); + return; + } + // Many tabs: one aggregate confirm, then close each without re-asking. + if (get().confirmOnCloseSession) { + const ok = await confirmDialog({ + title: 'Close sessions?', + message: `Close ${ids.length} sessions? This ends all of them.`, + confirmText: `Close ${ids.length}`, + danger: true, + }); + if (!ok) return; + } + for (const id of ids) await get().closeTerminal(id, { skipConfirm: true }); + }, + restoreClosedTerminal: async () => { const { closedTerminals } = get(); if (closedTerminals.length === 0) return; @@ -2646,6 +2690,12 @@ export const useTerminalStore = create((set, get) => ({ get().updateConfig({ hideTabCloseButtons: val } as any); }, + toggleConfirmOnCloseSession: () => { + const val = !get().confirmOnCloseSession; + set({ confirmOnCloseSession: val }); + get().updateConfig({ confirmOnCloseSession: val } as any); + }, + setTerminalOpacity: (opacity: number) => { const clamped = Math.max(0.3, Math.min(1, opacity)); set({ terminalOpacity: clamped });