diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index e9d4bd05b9a..031e4ff623b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -44,6 +44,7 @@ function renderOverlay({ paneCount = 2, showAlwaysOnHeaders = true, showSplitButton = true, + isTabPinned = false, onClosePane = vi.fn(), onRemoveTitle = vi.fn(), onRenameSubmit = vi.fn(), @@ -56,6 +57,7 @@ function renderOverlay({ paneCount?: number showAlwaysOnHeaders?: boolean showSplitButton?: boolean + isTabPinned?: boolean onClosePane?: ReturnType onRemoveTitle?: ReturnType onRenameSubmit?: ReturnType @@ -69,7 +71,7 @@ function renderOverlay({ onRemoveTitle: ReturnType onRenameSubmit: ReturnType } { - const panes = [makePane(1), makePane(2)] + const panes = [makePane(1), makePane(2)].slice(0, paneCount) const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) @@ -81,6 +83,7 @@ function renderOverlay({ cwd={path.join(path.sep, 'tmp')} showAlwaysOnHeaders={showAlwaysOnHeaders} showSplitButton={showSplitButton} + isTabPinned={isTabPinned} paneCount={paneCount} activePaneId={1} panes={panes} @@ -145,7 +148,7 @@ afterEach(() => { }) describe('TerminalPaneHeaderOverlay', () => { - it('keeps the titled-pane close affordance as remove-title while headers are always on', () => { + it('keeps the titled split-pane X as remove-title only', () => { const { container, onClosePane, onRemoveTitle } = renderOverlay({ paneTitles: { 1: 'server', 2: '' } }) @@ -154,15 +157,34 @@ describe('TerminalPaneHeaderOverlay', () => { 'button[aria-label="Remove pane title: server"]' ) expect(removeTitle).not.toBeNull() + expect( + container.querySelector('.pane-title-bar[data-active-pane] button[aria-label="Close Pane"]') + ).toBeNull() act(() => removeTitle?.click()) expect(onRemoveTitle).toHaveBeenCalledWith(1) - expect(onClosePane).not.toHaveBeenCalledWith(1) + expect(onClosePane).not.toHaveBeenCalled() + }) + + it('offers close tab beside remove-title for a titled single pane', () => { + const { container, onClosePane, onRemoveTitle } = renderOverlay({ + paneTitles: { 1: 'server' }, + paneCount: 1 + }) + + expect(container.querySelector('button[aria-label="Remove pane title: server"]')).not.toBeNull() + const closeTab = container.querySelector('button[aria-label="Close tab"]') + expect(closeTab).not.toBeNull() + + act(() => closeTab?.click()) + + expect(onClosePane).toHaveBeenCalledWith(1) + expect(onRemoveTitle).not.toHaveBeenCalled() }) it('keeps split and close-pane controls available for untitled split pane headers', () => { - const { container, onClosePane, onRemoveTitle } = renderOverlay({ + const { container, onClosePane } = renderOverlay({ paneTitles: { 1: '', 2: '' } }) @@ -174,7 +196,31 @@ describe('TerminalPaneHeaderOverlay', () => { act(() => closePane?.click()) expect(onClosePane).toHaveBeenCalledWith(1) - expect(onRemoveTitle).not.toHaveBeenCalled() + }) + + it('offers close tab for an untitled single pane', () => { + const { container, onClosePane } = renderOverlay({ paneTitles: { 1: '' }, paneCount: 1 }) + + const closeTab = container.querySelector('button[aria-label="Close tab"]') + expect(closeTab).not.toBeNull() + expect(container.querySelector('button[aria-label="Close Pane"]')).toBeNull() + + act(() => closeTab?.click()) + + expect(onClosePane).toHaveBeenCalledWith(1) + }) + + it.each([ + { label: 'untitled', title: '' }, + { label: 'titled', title: 'server' } + ])('keeps a pinned $label single-pane tab without a close button', ({ title }) => { + const { container } = renderOverlay({ + paneTitles: { 1: title }, + paneCount: 1, + isTabPinned: true + }) + + expect(container.querySelector('button[aria-label="Close tab"]')).toBeNull() }) it('omits the split control when the header affordance is hidden', () => { @@ -185,6 +231,7 @@ describe('TerminalPaneHeaderOverlay', () => { }) expect(container.querySelector('button[aria-label="Split Terminal Right"]')).toBeNull() + expect(container.querySelector('button[aria-label="Close tab"]')).toBeNull() }) it('ignores IME composition Enter before submitting a pane title rename', () => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 6256bc64c07..cebeabd9bb7 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -28,6 +28,7 @@ type TerminalPaneHeaderOverlayProps = { showAlwaysOnHeaders: boolean /** Used by ephemeral one-off command terminals that omit the header affordance. */ showSplitButton?: boolean + isTabPinned: boolean paneCount: number activePaneId: number | null | undefined panes: readonly ManagedPane[] @@ -73,6 +74,7 @@ export default function TerminalPaneHeaderOverlay({ cwd, showAlwaysOnHeaders, showSplitButton = true, + isTabPinned, paneCount, activePaneId, panes, @@ -126,6 +128,17 @@ export default function TerminalPaneHeaderOverlay({ const isActivePane = activePaneId === pane.id const isChromeless = showAlwaysOnHeaders && !title && !isEditing const showHeader = overlayRect && (showAlwaysOnHeaders || Boolean(title) || isEditing) + const closeLabel = + paneCount > 1 + ? translate( + 'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', + 'Close Pane' + ) + : translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab') + // Why: a titled split pane keeps its X as remove-title, but a titled single + // pane (agent terminals get runtime titles) still needs a close control. + const showCloseButton = + showAlwaysOnHeaders && (paneCount > 1 ? !title : showSplitButton && !isTabPinned) if (!showHeader || !overlayRect) { return null } @@ -368,7 +381,8 @@ export default function TerminalPaneHeaderOverlay({ )} - ) : paneCount > 1 && showAlwaysOnHeaders ? ( + ) : null} + {showCloseButton ? ( - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', - 'Close Pane' - )} + {closeLabel} ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 8165537e39c..08613fb6c3a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -63,6 +63,7 @@ export function TerminalPaneSurface({ handleToggleNativeChat, hiddenStartupStyle, isActive, + isTabPinned, keybindings, managedPanes, managerRef, @@ -302,6 +303,7 @@ export function TerminalPaneSurface({ cwd={cwd ?? ''} showAlwaysOnHeaders={isActive && terminalContentVisible} showSplitButton={showSplitButton} + isTabPinned={isTabPinned} paneCount={paneCount} activePaneId={activePane?.id} panes={managedPanes} diff --git a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts index c4eaff387dc..b21c6794aac 100644 --- a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import { getCachedTerminalGroupIdForWorktree, - getCachedUnifiedTerminalTabForWorktree + getCachedUnifiedTerminalTabForWorktree, + selectUnifiedTerminalTabFields } from './terminal-unified-tab-lookup' function makeTerminalTab(entityId: string, groupId: string): Tab { @@ -95,4 +96,15 @@ describe('terminal unified tab lookup', () => { expect(first.iterator).toHaveBeenCalledTimes(1) expect(second.iterator).toHaveBeenCalledTimes(1) }) + + it('reads pinned state from the selected terminal tab after it changes', () => { + const tab = { ...makeTerminalTab('terminal-1', 'group-a'), id: 'unified-1' } + expect( + selectUnifiedTerminalTabFields({ 'wt-1': [tab] }, 'wt-1', tab.entityId).isTabPinned + ).toBe(false) + expect( + selectUnifiedTerminalTabFields({ 'wt-1': [{ ...tab, isPinned: true }] }, 'wt-1', tab.entityId) + .isTabPinned + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts index 7df841441c5..46c0f8afe30 100644 --- a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts @@ -1,9 +1,10 @@ import type { Tab } from '../../../../shared/tab-types' -export type UnifiedTerminalTabChatFields = { +export type UnifiedTerminalTabFields = { unifiedTabId: string | undefined isChatViewMode: boolean unifiedTabLabel: string | undefined + isTabPinned: boolean } const terminalTabLookupByUnifiedTabs = new WeakMap>() @@ -46,16 +47,16 @@ export function getCachedTerminalGroupIdForWorktree( } /** - * The unified-tab fields TerminalPane's chat state reads. + * The unified-tab fields TerminalPane reads. * * Why bundled: they used to be five `useAppStore` calls, so one publication paid * the lookup five times and held five listener slots for every mounted tab. */ -export function selectUnifiedTerminalTabChatFields( +export function selectUnifiedTerminalTabFields( unifiedTabsByWorktree: Record, worktreeId: string, terminalTabId: string -): UnifiedTerminalTabChatFields { +): UnifiedTerminalTabFields { const tab = getCachedUnifiedTerminalTabForWorktree( unifiedTabsByWorktree, worktreeId, @@ -64,6 +65,7 @@ export function selectUnifiedTerminalTabChatFields( return { unifiedTabId: tab?.id, isChatViewMode: tab?.viewMode === 'chat', - unifiedTabLabel: tab?.label + unifiedTabLabel: tab?.label, + isTabPinned: tab?.isPinned === true } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 3c5cc06c185..5f6985936b3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -7,7 +7,7 @@ import { collectLeafIdsInOrder, EMPTY_LAYOUT } from './layout-serialization' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' import { useTerminalPaneStoreActions } from './use-terminal-pane-store-actions' -import { selectUnifiedTerminalTabChatFields } from './terminal-unified-tab-lookup' +import { selectUnifiedTerminalTabFields } from './terminal-unified-tab-lookup' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { nativeChatLaunchAgentForLeaf, @@ -41,9 +41,9 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController const pendingCodexPaneRestartIds = useAppStore((store) => store.pendingCodexPaneRestartIds) // Why one selector: five separate subscriptions each re-read the same unified // tab, so one publication paid the lookup five times per mounted tab. - const { unifiedTabId, isChatViewMode, unifiedTabLabel } = useAppStore( + const { unifiedTabId, isChatViewMode, unifiedTabLabel, isTabPinned } = useAppStore( useShallow((store) => - selectUnifiedTerminalTabChatFields(store.unifiedTabsByWorktree, worktreeId, tabId) + selectUnifiedTerminalTabFields(store.unifiedTabsByWorktree, worktreeId, tabId) ) ) const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) @@ -243,6 +243,7 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController nativeChatEnabled, effectiveChatViewMode, unifiedTabLabel, + isTabPinned, runtimePaneTitlesByPaneId, tabAgentTypeByLeaf, setTabViewMode,