From 7c5e8a7f309cb9fb1d2ac3f96ce3e22cd377687d Mon Sep 17 00:00:00 2001 From: Wolfie Date: Tue, 23 Jun 2026 13:37:17 -0700 Subject: [PATCH] feat: floating workspace shortcuts and tab shortcut affordances (#6077) * feat: add floating workspace shortcuts * test: shim localStorage in happy-dom tests * refactor: tighten floating shortcut tests and docs - Drop unrelated localStorage test shim (local Node 25 only; CI runs Node 24) - Add why-comments for minify/minimize macOS-only and unbound defaults - Assert minify bounds via getMinifiedFloatingTerminalBounds() not magic numbers - Cover out-of-range tab index and no-active-tab rename edge cases * test: cover custom floating shortcut bindings on Linux/Windows Proves user-assigned overrides for floatingWorkspace.maximize/minify fire on platforms where the default bindings are intentionally empty. * feat: show keybinding hints in floating panel control tooltips Maximize/restore and minimize tooltips now surface their live shortcut as "Label (shortcut)", matching the repo convention (e.g. ReviewPRViewAnimatedVisual). Degrades to a bare label when the action is unbound (Win/Linux defaults, and minimize on every platform). * feat: rework floating panel resize shortcut to Cmd+Opt+Shift+A - Move maximize/restore default from Cmd+Opt+Up to Cmd+Opt+Shift+A so it shares the Cmd+Opt+A toggle anchor and stays one-handed. - Open-into-maximized: pressing the chord while the panel is closed opens it straight into the maximized state (one-shot intent bridges the closed->mounted gap), regardless of last saved size. While open it remains a maximize<->restore toggle returning to saved dimensions. - Drop the minify action and its Cmd+Opt+Down default entirely; it was introduced earlier in this PR and is not in main, so this trims unshipped surface rather than removing an existing feature. Maximize tooltip now surfaces the new chord. Adds panel tests for the open-maximized intent and updates keybinding default tests. * feat: surface tab shortcut affordances * refactor: address 5.5 Pro review on tab shortcut affordances - Add Darwin Option-composed (Cmd+Opt+Shift+A) maximize capture/round-trip regression - Replace 'Unassigned' string-sentinel checks with useOptionalShortcutLabel (string | null) - Add TTL guard + unit tests so a stale open-maximized intent cannot leak into a later open * feat: show default agent shortcut in new tab menu * fix: address PR review on tab tooltip a11y, i18n, and test guards * Fix floating workspace shortcut edge cases Co-authored-by: Orca --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/renderer/src/App.tsx | 24 +- .../FloatingTerminalPanel.test.tsx | 465 +++++++++++++++++- .../FloatingTerminalPanel.tsx | 206 ++++++-- .../FloatingTerminalWindowControls.tsx | 38 +- .../src/components/tab-bar/EditorFileTab.tsx | 29 +- .../tab-bar/EditorFileTabCloseButton.tsx | 56 +++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 52 +- .../tab-bar/EditorFileTabContextMenu.tsx | 11 +- .../tab-bar/QuickLaunchButton.test.ts | 155 +++++- .../components/tab-bar/QuickLaunchButton.tsx | 11 +- .../SortableTab.rename-shortcut.test.tsx | 10 +- .../src/components/tab-bar/SortableTab.tsx | 84 ++-- .../tab-bar/SortableTabContextMenu.test.tsx | 3 +- .../tab-bar/SortableTabContextMenu.tsx | 6 +- .../tab-bar/TabBar.context-menu.test.ts | 2 +- .../src/components/tab-bar/TabBar.tsx | 6 +- .../tab-bar/tab-title-tooltip.test.tsx | 14 + src/renderer/src/hooks/useShortcutLabel.ts | 20 + src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../src/lib/floating-terminal.test.ts | 45 ++ src/renderer/src/lib/floating-terminal.ts | 27 + src/shared/keybindings.test.ts | 102 ++++ src/shared/keybindings.ts | 53 ++ 27 files changed, 1313 insertions(+), 141 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx create mode 100644 src/renderer/src/lib/floating-terminal.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index aacb0a5c9e5..3bcbf8887c2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, isFloatingWorkspacePanelShortcut, @@ -1395,6 +1398,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1409,6 +1413,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1426,6 +1431,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1521,6 +1527,22 @@ function App(): React.JSX.Element { return } + // Why: when the floating workspace is closed, its own keydown handler is + // unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord + // here by opening the panel with a one-shot intent so it mounts straight + // into the maximized state. While the panel is open, this is a no-op: the + // panel's handler owns the maximize/restore toggle. + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + return + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 8306c7b9102..c571d145808 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -3,7 +3,7 @@ * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { KeybindingOverrides } from '../../../../shared/keybindings' +import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -14,6 +14,10 @@ import { getMaximizedFloatingTerminalBounds, type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' type EffectCallback = () => void | (() => void) @@ -32,6 +36,7 @@ type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record + renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -57,6 +62,7 @@ type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void + setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void @@ -65,7 +71,11 @@ type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record - settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } + settings: { + activeRuntimeEnvironmentId?: string | null + floatingTerminalCwd?: string + terminalShortcutPolicy?: TerminalShortcutPolicy + } } const hookRuntime = vi.hoisted(() => ({ @@ -102,6 +112,7 @@ const mocks = vi.hoisted(() => ({ pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), setActiveTab: vi.fn(), + setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -457,6 +468,7 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, + renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -470,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, + setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -593,10 +606,12 @@ function getPanelClassName(element: unknown): string { } function getMockedLocalStorage(): { + clear: ReturnType getItem: ReturnType setItem: ReturnType } { return window.localStorage as unknown as { + clear: ReturnType getItem: ReturnType setItem: ReturnType } @@ -637,6 +652,79 @@ function makeMacShortcutKeyEvent({ } } +function bindFocusedFloatingPanelKeydown(element: unknown): { + keydownListener: (event: unknown) => void + panelElement: { + contains: ReturnType + focus: ReturnType + closest: ReturnType + } +} { + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { + contains: vi.fn().mockReturnValue(true), + focus: vi.fn(), + closest: vi.fn() + } + panelElement.closest.mockImplementation((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: panelElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + return { keydownListener, panelElement } +} + +function makeFocusedPanelKeyEvent({ + altKey = false, + code, + ctrlKey = false, + key, + metaKey = false, + preventDefault = vi.fn(), + shiftKey = false, + stopImmediatePropagation = vi.fn(), + stopPropagation = vi.fn(), + target +}: { + altKey?: boolean + code?: string + ctrlKey?: boolean + key: string + metaKey?: boolean + preventDefault?: () => void + shiftKey?: boolean + stopImmediatePropagation?: () => void + stopPropagation?: () => void + target: unknown +}): unknown { + return { + altKey, + code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key), + ctrlKey, + defaultPrevented: false, + key, + metaKey, + preventDefault, + repeat: false, + shiftKey, + stopImmediatePropagation, + stopPropagation, + target + } +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -646,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.values = [] saveDialogBox.fileId = null resetStore() + // Why: the open-maximized intent is a module singleton; drain any leftover + // from a prior test so it cannot bleed into an unrelated render. + consumeFloatingTerminalOpenMaximizedIntent() mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' })) mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false) mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false) @@ -655,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { + clear: vi.fn(), getItem: vi.fn(() => null), removeItem: vi.fn(), setItem: vi.fn() @@ -1399,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2') }) + it('routes focused floating tab rename shortcuts to the active floating tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() + }) + + it('routes focused floating tab index shortcuts to the matching visible tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit3', + ctrlKey: true, + key: '3', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('tab-3') + expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + }) + + it('routes focused floating tab index shortcuts across mixed visible tab types', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const terminalTab = makeTab({ id: 'terminal-tab' }) + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + const browserTab: BrowserTab = { + id: 'browser-tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: '', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 2 + } + const browserUnifiedTab: Tab = { + id: 'browser-unified-tab', + entityId: browserTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } + const terminalUnifiedTab: Tab = { + id: terminalTab.id, + entityId: terminalTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: terminalTab.title, + customLabel: terminalTab.customTitle, + color: terminalTab.color, + sortOrder: 0, + createdAt: terminalTab.createdAt + } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] } + state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] } + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: terminalUnifiedTab.id, + tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id], + recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalUnifiedTab.id, + simulatorTab.id, + browserUnifiedTab.id + ] + } + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab') + }) + + it('ignores focused floating tab index shortcuts past the visible tab count', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit5', + ctrlKey: true, + key: '5', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(stopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).not.toHaveBeenCalled() + }) + + it('ignores focused floating tab rename shortcuts when no tab is active', async () => { + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + }) + + it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + ;(storeBox.state as FloatingPanelStoreState).settings = { + ...(storeBox.state as FloatingPanelStoreState).settings, + terminalShortcutPolicy: 'terminal-first' + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + const ctrlPreventDefault = vi.fn() + const ctrlStopPropagation = vi.fn() + const ctrlStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault: ctrlPreventDefault, + stopImmediatePropagation: ctrlStopImmediatePropagation, + stopPropagation: ctrlStopPropagation, + target + }) + ) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const altPreventDefault = vi.fn() + const altStopPropagation = vi.fn() + const altStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + code: 'Digit2', + key: '2', + preventDefault: altPreventDefault, + stopImmediatePropagation: altStopImmediatePropagation, + stopPropagation: altStopPropagation, + target + }) + ) + + expect(ctrlPreventDefault).not.toHaveBeenCalled() + expect(ctrlStopPropagation).not.toHaveBeenCalled() + expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled() + expect(altPreventDefault).not.toHaveBeenCalled() + expect(altStopPropagation).not.toHaveBeenCalled() + expect(altStopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + }) + + it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + + it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + requestFloatingTerminalOpenMaximized() + + await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + // Why: the intent is one-shot and must be consumed by the open transition. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('does not maximize on open when no intent is set', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + + const element = await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(element)).toEqual(savedBounds) + }) + + it('routes focused floating workspace minimize shortcuts to close the panel', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.minimize': ['Ctrl+Alt+N'] + } as unknown as KeybindingOverrides + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'n', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index f91eded8216..f7c4c31e9b8 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -58,6 +58,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingContext, type KeybindingMatchOptions, @@ -94,6 +95,7 @@ import { type FloatingTerminalPanelBoundsSource } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' +import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const EMPTY_GROUPS: TabGroup[] = [] @@ -376,6 +378,26 @@ export function FloatingTerminalPanel({ }), [activeGroup, groupTabs] ) + const visibleFloatingTabOrder = useMemo( + () => + tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator') { + return simulatorItems.some((item) => item.id === tab.id) + } + return editorItems.some((item) => item.tabId === tab.id) + }), + [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] + ) const activeBrowserTab = activeBrowserId ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) : null @@ -963,21 +985,91 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const toggleMaximized = useCallback(() => { + if (maximized) { + const restoredState = restoreBoundsRef.current ?? { + committedBounds: getDefaultFloatingTerminalCommittedBounds(), + renderedBounds: getDefaultFloatingTerminalBounds(), + source: 'default' as const + } + restoreBoundsRef.current = null + boundsSourceRef.current = restoredState.source + committedBoundsRef.current = restoredState.committedBounds + const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) + ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) + : restoredState.renderedBounds + stagedBoundsRef.current = null + setBounds(restoredBounds) + setMaximized(false) + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + const maximizePanel = useCallback(() => { + // Why: idempotent maximize used by the open-into-maximized intent. Unlike + // toggleMaximized it never restores, and only stashes restore bounds on the + // first transition so a redundant call cannot clobber the saved size. + if (maximized) { + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + useEffect(() => { + // Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed, + // it records a one-shot intent; honor it once the panel is open so it + // starts maximized regardless of its last saved size. + if (open && consumeFloatingTerminalOpenMaximizedIntent()) { + maximizePanel() + } + }, [open, maximizePanel]) + const handleFloatingPanelShortcutAction = useCallback( (input: FloatingPanelShortcutInput, consume: () => void): boolean => { const state = useAppStore.getState() const platform = getShortcutPlatform() + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target) const context: KeybindingContext = input.doubleTapModifier ? 'app' - : isFloatingWorkspaceTerminalInputTarget(input.target) + : isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + // Floating panel chrome owns these controls even when xterm has DOM focus; + // keep the rest of the shortcut table in terminal context for terminal-first. + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const matches = (actionId: KeybindingActionId): boolean => keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + const matchesFloatingChrome = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction( + actionId, + input, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if (matches('tab.newTerminal')) { consume() @@ -1013,10 +1105,43 @@ export function FloatingTerminalPanel({ } return true } + if (matchesFloatingChrome('tab.rename') && activeTab) { + consume() + state.setRenamingTabId(activeTab.id) + return true + } + const selectedTabIndex = matchKeybindingDigitIndex( + 'tab.selectByIndex', + input, + platform, + state.keybindings, + matchOptions + ) + if (selectedTabIndex !== null) { + const visibleId = visibleFloatingTabOrder[selectedTabIndex] + if (!visibleId) { + return false + } + consume() + activateFloatingItem(visibleId) + return true + } + if (matchesFloatingChrome('floatingWorkspace.maximize')) { + consume() + toggleMaximized() + return true + } + if (matchesFloatingChrome('floatingWorkspace.minimize')) { + consume() + onOpenChange(false) + return true + } return false }, [ activeClosableTab, + activeTab, + activateFloatingItem, closeFloatingItem, createFloatingBrowserTab, createFloatingMarkdownTab, @@ -1024,7 +1149,9 @@ export function FloatingTerminalPanel({ focusPanelForShortcutsAfterClose, onOpenChange, openFloatingMarkdownTab, - visibleFloatingItemCount + toggleMaximized, + visibleFloatingItemCount, + visibleFloatingTabOrder ] ) @@ -1044,14 +1171,47 @@ export function FloatingTerminalPanel({ const state = useAppStore.getState() const platform = getShortcutPlatform() - const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) - ? 'terminal' - : 'app' + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target) + const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const nativeEvent = event.nativeEvent + const isFloatingChromeShortcut = + keybindingMatchesAction( + 'tab.rename', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + matchKeybindingDigitIndex( + 'tab.selectByIndex', + nativeEvent, + platform, + state.keybindings, + matchOptions + ) !== null || + keybindingMatchesAction( + 'floatingWorkspace.maximize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + keybindingMatchesAction( + 'floatingWorkspace.minimize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if ( !isFloatingWorkspacePanelShortcut( @@ -1060,7 +1220,8 @@ export function FloatingTerminalPanel({ panelRef.current, state.keybindings, matchOptions - ) + ) && + !isFloatingChromeShortcut ) { return } @@ -1245,35 +1406,6 @@ export function FloatingTerminalPanel({ window.removeEventListener('blur', handleWindowBlur) } }, [open]) - - const toggleMaximized = useCallback(() => { - if (maximized) { - const restoredState = restoreBoundsRef.current ?? { - committedBounds: getDefaultFloatingTerminalCommittedBounds(), - renderedBounds: getDefaultFloatingTerminalBounds(), - source: 'default' as const - } - restoreBoundsRef.current = null - boundsSourceRef.current = restoredState.source - committedBoundsRef.current = restoredState.committedBounds - const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) - ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) - : restoredState.renderedBounds - stagedBoundsRef.current = null - setBounds(restoredBounds) - setMaximized(false) - return - } - restoreBoundsRef.current = { - committedBounds: committedBoundsRef.current, - renderedBounds: bounds, - source: boundsSourceRef.current - } - stagedBoundsRef.current = null - setBounds(getMaximizedFloatingTerminalBounds()) - setMaximized(true) - }, [bounds, maximized]) - const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 950378ae1d1..514402a0f36 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -16,6 +16,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -26,6 +27,14 @@ type FloatingTerminalWindowControlsProps = { const controlButtonClassName = 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground' +// Why: matches the repo convention (e.g. ReviewPRViewAnimatedVisual) of +// surfacing the live keybinding in a tooltip as "Label (shortcut)", while +// degrading to a bare label when the action is unbound (default on Win/Linux, +// and for minimize on every platform). +function withShortcutHint(label: string, shortcutLabel: string | null): string { + return shortcutLabel ? `${label} (${shortcutLabel})` : label +} + export function FloatingTerminalWindowControls({ maximized, onToggleMaximized, @@ -34,6 +43,8 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') + const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const defaultAgent = @@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({ {maximized - ? translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', - 'Restore' + ? withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', + 'Restore' + ), + maximizeShortcutLabel ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', - 'Maximize' + : withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', + 'Maximize' + ), + maximizeShortcutLabel )} @@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({ - {translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', - 'Minimize' + {withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', + 'Minimize' + ), + minimizeShortcutLabel )} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 6694b357ddb..940ad6df996 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' +import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' export default function EditorFileTab({ file, @@ -341,28 +342,14 @@ export default function EditorFileTab({ When clean: close button is shown normally (visible on active tab, on hover for others). */}
{file.isDirty && ( - + )} {!isPinned && ( - + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx new file mode 100644 index 00000000000..fb135b92c9e --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -0,0 +1,56 @@ +import { X } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +export function EditorFileTabCloseButton({ + fileIsDirty, + showsSelectionChrome, + onClose +}: { + fileIsDirty: boolean + showsSelectionChrome: boolean + onClose: () => void +}): React.JSX.Element { + const closeShortcut = useShortcutKeyDetails('tab.close') + + return ( + + + + + + + {translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')} + + {closeShortcut.keys.length > 0 && ( + + )} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 934d93ecaa6..8167bdbda5c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) +const shortcutLabelMock = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed -// label so the test asserts the shortcut is surfaced, not its platform glyphs. +// Why: the menu reads live shortcut bindings; stub them to fixed labels so +// the test asserts each assigned action surfaces its own shortcut chip. vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: shortcutLabelMock + useOptionalShortcutLabel: shortcutLabelMock })) const useAppStoreMock = Object.assign( @@ -208,10 +208,23 @@ async function renderMenu(): Promise { }) } +function assignedShortcutLabel(actionId: string): string | null { + switch (actionId) { + case 'tab.rename': + return '⌘R' + case 'tab.close': + return '⌘W' + case 'tab.closeAll': + return '⌘⌥W' + default: + return null + } +} + describe('EditorFileTabContextMenu close-all shortcut', () => { beforeEach(() => { vi.resetModules() - shortcutLabelMock.mockReturnValue('⌘⌥W') + shortcutLabelMock.mockImplementation(assignedShortcutLabel) vi.stubGlobal('navigator', { userAgent: 'Mac' }) }) @@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) - it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) + const menuItems = findElementsByType(tree, 'DropdownMenuItem') - const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename')) + const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W') + const closeAllItem = menuItems.find((item) => extractText(item.props.children).includes('Close All Editor Tabs') ) + expect(renameItem).toBeTruthy() + expect(closeItem).toBeTruthy() expect(closeAllItem).toBeTruthy() - const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') - expect(shortcut).toHaveLength(1) - expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + const shortcutExpectations: [ReactElementLike | undefined, string][] = [ + [renameItem, '⌘R'], + [closeItem, '⌘W'], + [closeAllItem, '⌘⌥W'] + ] - // Why: the shortcut hint is exclusive to Close All; sibling items (Close, - // Close Tabs To The Right) must not sprout their own chips. - expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + for (const [item, expectedLabel] of shortcutExpectations) { + const shortcut = findElementsByType(item, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe(expectedLabel) + } + + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) it('hides the shortcut chip when close-all is unassigned', async () => { - shortcutLabelMock.mockReturnValue('Unassigned') + shortcutLabelMock.mockReturnValue(null) const tree = expandNode(await renderMenu()) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 95943a77156..184c05d2d97 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({ onCloseToRight, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { - const closeAllShortcut = useShortcutLabel('tab.closeAll') - const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' + const renameShortcut = useOptionalShortcutLabel('tab.rename') + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') return ( @@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({ > {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} + {renameShortcut ? {renameShortcut} : null} @@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({ !isPinned && onClose()} disabled={isPinned}> {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} + {closeShortcut ? {closeShortcut} : null} {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' )} - {showCloseAllShortcut ? ( + {closeAllShortcut ? ( {closeAllShortcut} ) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 91d25847e87..3c32fdd7c99 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,5 +1,156 @@ -import { describe, expect, it } from 'vitest' -import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' + +const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( + () => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] + }, + worktreesByRepo: {}, + repos: [], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn() + }) +) + +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) + + return { useAppStore } +}) + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + getAgentCatalog: () => [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini' } + ], + AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent) + } +}) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement('div', props, children), + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: vi.fn() +})) + +function renderAgentMenuItems(): string { + return renderToStaticMarkup( + React.createElement(QuickLaunchAgentMenuItems, { + worktreeId: 'worktree-1', + groupId: 'group-1', + onFocusTerminal: vi.fn() + }) + ) +} + +function rowMarkup(html: string, label: string): string { + const start = html.indexOf(`title="Launch ${label} in a new terminal"`) + expect(start).toBeGreaterThanOrEqual(0) + const end = html.indexOf('', start) + expect(end).toBeGreaterThan(start) + + return html.slice(start, end) +} + +beforeEach(() => { + shortcutLabelMock.mockReset() + shortcutLabelMock.mockReturnValue(null) + openSettingsPageMock.mockReset() + openSettingsTargetMock.mockReset() + storeState.settings.defaultTuiAgent = 'codex' + storeState.settings.disabledTuiAgents = [] + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.openSettingsPage = openSettingsPageMock + storeState.openSettingsTarget = openSettingsTargetMock +}) + +describe('QuickLaunchAgentMenuItems', () => { + it('renders the new-agent shortcut next to the configured default agent only', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + const html = renderAgentMenuItems() + + expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1) + expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T') + expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T') + expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T') + }) + + it('hides the default-agent shortcut when the action is unbound', () => { + shortcutLabelMock.mockReturnValue(null) + + const html = renderAgentMenuItems() + + expect(html).not.toContain('data-dropdown-shortcut="true"') + }) + + it('does not label an auto-picked or blank default as configured', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + storeState.settings.defaultTuiAgent = null + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + + storeState.settings.defaultTuiAgent = 'blank' + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + }) +}) describe('shouldShowLaunchWatchdogTimeout', () => { it('does not report slow agent readiness once a PTY exists', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 5e4b0f9ce57..84681bbc8cc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - {label} + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} ) })} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index b47ec987e66..b22e1912d9e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\' + formatShortcutLabel: () => '⌘⇧\\', + useOptionalShortcutLabel: () => '⌘W', + useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { return { type: 'DropdownMenuItem', props } }, - DropdownMenuSeparator: function DropdownMenuSeparator() { - return { type: 'DropdownMenuSeparator', props: {} } - }, DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { return { type: 'DropdownMenuLabel', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 80b625f6885..41426802753 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' type SortableTabProps = { tab: TerminalTab @@ -207,6 +209,7 @@ export default function SortableTab({ disabled: isEditing }) const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - + + + + + + {translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')} + {closeShortcut.keys.length > 0 && ( + + )} + + )}
) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 391eb8ddb7c..71b067fa898 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘D' + formatShortcutLabel: () => '⌘D', + useOptionalShortcutLabel: () => '⌘D' })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 2997ab2a29c..291deb92466 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' -import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' @@ -126,6 +126,8 @@ export function SortableTabContextMenu({ } requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) } + const closeShortcut = useOptionalShortcutLabel('tab.close') + const renameShortcut = useOptionalShortcutLabel('tab.rename') return ( @@ -165,6 +167,7 @@ export function SortableTabContextMenu({ !isPinned && onClose(tab.id)} disabled={isPinned}> {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} + {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} @@ -178,6 +181,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} + {renameShortcut ? {renameShortcut} : null}
diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index fd5d8ca9a90..9e313bccd55 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => { ) expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toBe('Open Markdown...') + expect(menuLabels[1]).toContain('Open Markdown...') expect(menuLabels[2]).toContain('New Terminal') expect(menuLabels[3]).toContain('New Browser Tab') }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 0455c4fb64b..f61ff4aff33 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -51,7 +51,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, WINDOWS_GIT_BASH_SHELL @@ -272,6 +272,7 @@ function TabBarInner({ const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -761,6 +762,9 @@ function TabBarInner({ > {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} ) : null const mobileEmulatorIntroMenuBlock = diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index f6fa605e219..920470534ad 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string { return match[0] } +function textSpanHtml(markup: string, text: string): string { + const textIndex = markup.indexOf(`>${text}`) + expect(textIndex).toBeGreaterThanOrEqual(0) + + const tagStart = markup.lastIndexOf('', textIndex) + expect(spanEnd).toBeGreaterThan(textIndex) + + return markup.slice(tagStart, spanEnd + ''.length) +} + function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' @@ -357,6 +370,7 @@ describe('tab title tooltips', () => { expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expect(root).toContain('data-tab-id="editor-tab-1"') + expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed') expectTabContainerWidth(markup, root) }) }) diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 6a92064f25b..9f15743ad40 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string { return formatShortcutLabel(actionId, keybindings) } +// Why: returns null for unbound actions instead of the display sentinel +// 'Unassigned', so callers decide whether to render a hint without coupling +// UI logic to formatter copy (which may change or become localized). +export function formatOptionalShortcutLabel( + actionId: KeybindingActionId, + overrides?: KeybindingOverrides +): string | null { + const platform = getShortcutPlatform() + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) +} + +export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { + const keybindings = useAppStore((state) => state.keybindings) + return formatOptionalShortcutLabel(actionId, keybindings) +} + export function formatShortcutKeys( actionId: KeybindingActionId, overrides?: KeybindingOverrides diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a699f1d5812..86a665eafb7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Rename tab {{value0}}", "6df69d9388": "Close tab {{value0}}", - "fdb2691425": "Collapse pane" + "fdb2691425": "Collapse pane", + "95db5f2f7d": "Close tab" }, "SortableTabContextMenu": { "35e8892fd0": "Tab Color", @@ -2624,6 +2625,10 @@ "down": "Down", "up": "Up", "moveToPaneColumn": "Move Tab to Split" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Close tab", + "a768f428f1": "Close tab" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac7092448ff..f4592d35d23 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Cambiar nombre de pestaña {{value0}}", "6df69d9388": "Cerrar pestaña {{value0}}", - "fdb2691425": "Contraer panel" + "fdb2691425": "Contraer panel", + "95db5f2f7d": "Cerrar pestaña" }, "SortableTabContextMenu": { "35e8892fd0": "Color de pestaña", @@ -2624,6 +2625,10 @@ "down": "Abajo", "up": "Arriba", "moveToPaneColumn": "Mover pestaña a división" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Cerrar pestaña", + "a768f428f1": "Cerrar pestaña" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2ceb16b354d..205a953ec87 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "タブ {{value0}} の名前を変更します", "6df69d9388": "タブ {{value0}} を閉じる", - "fdb2691425": "ペインを折りたたむ" + "fdb2691425": "ペインを折りたたむ", + "95db5f2f7d": "タブを閉じる" }, "SortableTabContextMenu": { "35e8892fd0": "タブの色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "タブを分割へ移動" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "タブを閉じる", + "a768f428f1": "タブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7a22cac315f..60f7ac0ebbb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "탭 이름 바꾸기 {{value0}}", "6df69d9388": "탭 닫기 {{value0}}", - "fdb2691425": "창 축소" + "fdb2691425": "창 축소", + "95db5f2f7d": "탭 닫기" }, "SortableTabContextMenu": { "35e8892fd0": "탭 색상", @@ -2624,6 +2625,10 @@ "down": "아래", "up": "위", "moveToPaneColumn": "탭을 분할로 이동" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "탭 닫기", + "a768f428f1": "탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d2d12868b6..0c5d2d3c9fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "重命名选项卡 {{value0}}", "6df69d9388": "关闭选项卡 {{value0}}", - "fdb2691425": "折叠窗格" + "fdb2691425": "折叠窗格", + "95db5f2f7d": "关闭选项卡" }, "SortableTabContextMenu": { "35e8892fd0": "标签颜色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "将标签页移至拆分" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "关闭选项卡", + "a768f428f1": "关闭选项卡" } } }, diff --git a/src/renderer/src/lib/floating-terminal.test.ts b/src/renderer/src/lib/floating-terminal.test.ts new file mode 100644 index 00000000000..ea52f857a88 --- /dev/null +++ b/src/renderer/src/lib/floating-terminal.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from './floating-terminal' + +describe('floating terminal open-maximized intent', () => { + afterEach(() => { + vi.useRealTimers() + // Drain any leftover intent so it cannot bleed into an unrelated test. + consumeFloatingTerminalOpenMaximizedIntent() + }) + + it('returns true exactly once after a request', () => { + requestFloatingTerminalOpenMaximized() + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + // One-shot: a second consume without a new request is false. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('returns false when no request was made', () => { + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('expires a stale intent so an abandoned open does not leak into a later open', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + // Why: the open was abandoned (prevented/interrupted before the panel + // mounted); a much-later ordinary open must not consume the stale intent. + vi.advanceTimersByTime(2001) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('still honors an intent consumed within the same interaction window', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + vi.advanceTimersByTime(50) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/floating-terminal.ts b/src/renderer/src/lib/floating-terminal.ts index 85a53f5d77f..b496590cff2 100644 --- a/src/renderer/src/lib/floating-terminal.ts +++ b/src/renderer/src/lib/floating-terminal.ts @@ -1 +1,28 @@ export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal' + +// Why: maximize/restore lives in the panel's own keydown handler, but that +// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is +// pressed with the panel closed, App opens it and records a one-shot intent +// here so the freshly mounted panel starts maximized instead of at its last +// saved size. A module singleton (not a prop) bridges the closed→mounted gap +// that React state cannot, and is consumed exactly once. +let openMaximizedIntentAt: number | null = null + +// Why: the panel mounts within the same interaction as the request, so an +// intent older than this window means the open was abandoned (prevented or +// interrupted before mount). Expiring it stops a stale intent from leaking +// into a later ordinary open and maximizing it unexpectedly. +const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000 + +export function requestFloatingTerminalOpenMaximized(): void { + openMaximizedIntentAt = Date.now() +} + +export function consumeFloatingTerminalOpenMaximizedIntent(): boolean { + if (openMaximizedIntentAt === null) { + return false + } + const requestedAt = openMaximizedIntentAt + openMaximizedIntentAt = null + return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS +} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 1228d7bbdd6..83db7e37efd 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -21,6 +21,7 @@ import { normalizeKeybindingListForAction, normalizeKeybindingList } from './keybindings' +import type { KeybindingActionId, KeybindingPlatform } from './keybindings' import { ALL_TUI_AGENTS } from './tui-agent-display-names' describe('keybindings', () => { @@ -250,6 +251,22 @@ describe('keybindings', () => { 'linux' ) ).toBe(false) + expect( + keybindingMatchesAction( + 'tab.rename', + { + key: 'r', + code: 'KeyR', + meta: true, + control: false, + alt: false, + shift: false + }, + 'darwin', + undefined, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(false) // Why: tab.rename (Mod+R) intentionally shares its binding with // browser.reload, but the two live in different scopes (tabs vs browser), @@ -439,6 +456,65 @@ describe('keybindings', () => { ) }) + it('defines floating workspace panel action metadata', () => { + const actionIds = [ + 'floatingWorkspace.maximize' as KeybindingActionId, + 'floatingWorkspace.minimize' as KeybindingActionId + ] as const + + for (const actionId of actionIds) { + expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId }) + } + }) + + it('assigns the floating workspace maximize default only on macOS', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A']) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([]) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([]) + }) + + it('captures and round-trips the macOS Option-composed maximize chord', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + // Why: macOS Option+A composes to a glyph (å), so capture must resolve the + // chord through the physical-code fallback rather than the composed key, + // matching the matcher so a user override round-trips to the same binding. + const macComposedMaximize = { + key: 'å', + code: 'KeyA', + meta: true, + control: false, + alt: true, + shift: true + } + expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({ + ok: true, + value: 'Mod+Alt+Shift+A' + }) + expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true) + // The captured override formats back to the same effective shortcut. + expect( + getEffectiveKeybindingsForAction(maximizeAction, 'darwin', { + [maximizeAction]: ['Mod+Alt+Shift+A'] + }) + ).toEqual(['Mod+Alt+Shift+A']) + expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A') + }) + + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { + const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId + + for (const platform of platforms) { + expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([]) + } + expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([ + 'Mod+Alt+A' + ]) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) @@ -533,6 +609,32 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { + const panelFocus = { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + } as const + + expect( + keybindingMatchesAction( + 'tab.rename', + { key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false }, + 'darwin', + undefined, + panelFocus + ) + ).toBe(true) + expect( + matchKeybindingDigitIndex( + 'tab.selectByIndex', + { key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false }, + 'linux', + undefined, + panelFocus + ) + ).toBe(3) + }) + it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => { const deleteBinding = { key: 'Backspace', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4f514c25626..57a8b775267 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -50,6 +50,8 @@ export type KeybindingActionId = | 'sidebar.ports.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' @@ -408,6 +410,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so + // maximize/restore lives on the same key anchor and stays one-handed, + // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; + // Linux/Windows stay unbound for users to assign. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: intentionally unbound on every platform. floatingTerminal.toggle + // already owns the default show/hide chord; this action exists only so + // users can bind an explicit "hide the focused panel" shortcut in Settings. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, { id: 'zoom.in', title: 'Zoom In',