From cbd06a76713cce281cc95a4df05238a57d45ea18 Mon Sep 17 00:00:00 2001 From: Agustina Chaer Date: Tue, 30 Jun 2026 22:21:37 -0300 Subject: [PATCH] feat(tab-bar): add shortcut to open commands for active tab group (#6325) * Add keyboard shortcut to toggle the Quick Commands menu - New `tab.openQuickCommandsMenu` keybinding action (no default binding) - TabBarQuickCommandsMenu listens for the binding and toggles open/closed - Scoped to the active tab group naturally since the component only mounts when its group is focused * Show keyboard shortcut in Quick Commands menu trigger tooltip * Add tests * expand tests * Expand keyboard toggle to call handleOpenChange and skip repeated keys - Replace `setMenuOpen` toggle with `handleOpenChange(!menuOpen)` so closing via keyboard runs the same reset logic (query, focus frame, value override) - Guard against key-repeat events to prevent rapid toggling on held key - Wrap `handleOpenChange` in `useCallback` so it's stable enough to include in the `useEffect` dependency array without causing spurious re-registrations - Update tests to reflect that re-running the effect between presses is required for the close path, and add a repeat-event test * Add docstring to withShortcutHint func * review: harden quick commands menu shortcut Co-authored-by: Orca --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/browser/browser-guest-ui.test.ts | 23 ++ src/main/browser/browser-guest-ui.ts | 2 + .../claude-agent-teams-service.test.ts | 16 +- src/main/window/createMainWindow.test.ts | 64 +++ src/main/window/createMainWindow.ts | 8 + src/preload/api-types.ts | 1 + src/preload/index.ts | 5 + .../tab-bar/TabBarQuickCommandItem.tsx | 80 ++++ .../TabBarQuickCommandsMenu.keyboard.test.ts | 385 ++++++++++++++++++ .../tab-bar/TabBarQuickCommandsMenu.tsx | 203 ++++----- .../tab-bar-quick-commands-shortcut.ts | 150 +++++++ .../regular-terminal-focus-ownership.ts | 10 +- src/renderer/src/hooks/useIpcEvents.test.ts | 11 + src/renderer/src/hooks/useIpcEvents.ts | 7 + .../src/lib/quick-commands-menu-events.ts | 1 + src/renderer/src/web/web-preload-api.ts | 1 + src/shared/keybindings.test.ts | 97 +++++ src/shared/keybindings.ts | 75 +++- src/shared/window-shortcut-policy.test.ts | 48 +++ src/shared/window-shortcut-policy.ts | 7 + 20 files changed, 1088 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx create mode 100644 src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-bar-quick-commands-shortcut.ts create mode 100644 src/renderer/src/lib/quick-commands-menu-events.ts diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index 9e7d28098..6428352ea 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -530,6 +530,29 @@ describe('setupGuestShortcutForwarding', () => { expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:browserHistoryNavigate', 'forward') }) + it('forwards quick-command menu shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + getKeybindings: () => ({ + 'tab.openQuickCommandsMenu': ['Mod+Shift+Q'] + }) + }) + + const isMac = process.platform === 'darwin' + const preventDefault = triggerBeforeInput({ + code: 'KeyQ', + key: 'q', + meta: isMac, + control: !isMac, + shift: true + }) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu') + }) + it('consumes guest zoom shortcuts even when the renderer is unavailable', () => { setupGuestShortcutForwarding({ browserTabId, diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 4e5c307ae..d3de5906c 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -426,6 +426,8 @@ export function setupGuestShortcutForwarding(args: { renderer.send('ui:toggleWorktreePalette') } else if (action?.type === 'openQuickOpen') { renderer.send('ui:openQuickOpen') + } else if (action?.type === 'toggleQuickCommandsMenu') { + renderer.send('ui:toggleQuickCommandsMenu') } else if (action?.type === 'openNewWorkspace') { renderer.send('ui:openNewWorkspace') } else if (action?.type === 'openWorkspaceBoard') { diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index 643b58761..f02d470fc 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -167,8 +167,7 @@ describe('ClaudeAgentTeamsService', () => { await request(['set-option', '-p', '-t', '%2', 'remain-on-exit', 'failed']) - const teammateCommand = - 'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto' + const teammateCommand = 'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto' await expect( request(['respawn-pane', '-k', '-t', '%2', '--', teammateCommand]) ).resolves.toMatchObject({ stdout: '', exitCode: 0 }) @@ -224,10 +223,19 @@ describe('ClaudeAgentTeamsService', () => { await expect( service.handleTmuxCompat( - { teamId, token, envPane: leaderPane, argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat'] }, + { + teamId, + token, + envPane: leaderPane, + argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat'] + }, api ) - ).resolves.toMatchObject({ ok: false, exitCode: 1, stderr: 'tmux: refusing to respawn leader pane\n' }) + ).resolves.toMatchObject({ + ok: false, + exitCode: 1, + stderr: 'tmux: refusing to respawn leader pane\n' + }) }) it('rejects stale or unauthorized shim calls', async () => { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 6da19b814..212e7c20f 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -899,6 +899,70 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette') }) + it('suppresses auto-repeat quick-command menu toggles from before-input-event', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null, { + getKeybindings: () => ({ + 'tab.openQuickCommandsMenu': ['Mod+Shift+Q'] + }) + }) + + const isDarwin = process.platform === 'darwin' + const input = { + type: 'keyDown', + code: 'KeyQ', + key: 'q', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: true + } + const firstPreventDefault = vi.fn() + windowHandlers['before-input-event']({ preventDefault: firstPreventDefault } as never, input) + expect(firstPreventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu') + + webContents.send.mockClear() + const repeatPreventDefault = vi.fn() + windowHandlers['before-input-event']({ preventDefault: repeatPreventDefault } as never, { + ...input, + isAutoRepeat: true + }) + + expect(repeatPreventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).not.toHaveBeenCalled() + }) + it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index dd7e740a0..93f81569e 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -788,6 +788,9 @@ export function createMainWindow( case 'openQuickOpen': mainWindow.webContents.send('ui:openQuickOpen') return + case 'toggleQuickCommandsMenu': + mainWindow.webContents.send('ui:toggleQuickCommandsMenu') + return case 'openNewWorkspace': mainWindow.webContents.send('ui:openNewWorkspace') return @@ -862,6 +865,11 @@ export function createMainWindow( return true } + if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) { + event.preventDefault() + return true + } + event.preventDefault() if (capturedTerminalActionId) { mainWindow.webContents.send('ui:terminalShortcutCaptured', { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e134ed2de..fb219b6a5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2515,6 +2515,7 @@ export type PreloadApi = { callback: (data: { actionId: KeybindingActionId }) => void ) => () => void onOpenQuickOpen: (callback: () => void) => () => void + onToggleQuickCommandsMenu: (callback: () => void) => () => void onOpenNewWorkspace: (callback: () => void) => () => void onDeleteCurrentWorkspace: (callback: () => void) => () => void onOpenWorkspaceBoard: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index b1f83b4aa..8d0d20f2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2951,6 +2951,11 @@ const api = { ipcRenderer.on('ui:openQuickOpen', listener) return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) }, + onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) + return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) + }, onOpenNewWorkspace: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openNewWorkspace', listener) diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx new file mode 100644 index 000000000..37daea61f --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx @@ -0,0 +1,80 @@ +import { Pencil, Play, Trash2 } from 'lucide-react' +import { CommandItem } from '@/components/ui/command' +import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' +import type { TerminalQuickCommand } from '../../../../shared/types' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' + +type TabBarQuickCommandItemProps = { + command: TerminalQuickCommand + onRun: () => void + onEdit: () => void + onDelete: () => void +} + +export function TabBarQuickCommandItem({ + command, + onRun, + onEdit, + onDelete +}: TabBarQuickCommandItemProps): React.JSX.Element { + return ( + + {isTerminalAgentQuickCommand(command) ? ( + + + + ) : ( + + )} + + {command.label} + + {isTerminalAgentQuickCommand(command) + ? `${getAgentLabel(command.agent)}: ${command.prompt}` + : command.command} + + + + + + + + ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts new file mode 100644 index 000000000..51b8758d2 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts @@ -0,0 +1,385 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Capture window keydown listeners so tests can fire them directly. +const windowListeners = vi.hoisted(() => new Map void>()) + +const keybindingsMock = vi.hoisted(() => ({ + matchAction: vi.fn().mockReturnValue(false) +})) + +const appStoreMock = vi.hoisted(() => ({ + state: { + activeView: 'terminal' as 'terminal' | 'settings', + keybindings: {} as Record, + settings: { + terminalShortcutPolicy: 'orca-first' as 'orca-first' | 'terminal-first' + } + } +})) + +// Minimal React hook runtime: track useState values and useEffect callbacks. +const reactRuntime = vi.hoisted(() => ({ + states: [] as unknown[], + index: 0, + effects: [] as (() => void | (() => void))[] +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useState(initial: T | (() => T)) { + const i = reactRuntime.index++ + if (!(i in reactRuntime.states)) { + reactRuntime.states[i] = typeof initial === 'function' ? (initial as () => T)() : initial + } + const setState = (next: T | ((prev: T) => T)): void => { + reactRuntime.states[i] = + typeof next === 'function' ? (next as (p: T) => T)(reactRuntime.states[i] as T) : next + } + return [reactRuntime.states[i] as T, setState] as const + }, + useEffect(effect: () => void | (() => void)) { + reactRuntime.effects.push(effect) + }, + useCallback(fn: T): T { + return fn + }, + useMemo(fn: () => T): T { + return fn() + }, + useRef(init: T) { + return { current: init } + } + } +}) + +vi.mock('../../../../shared/keybindings', () => ({ + keybindingMatchesAction: keybindingsMock.matchAction +})) + +vi.mock('@/lib/shortcut-platform', () => ({ + getShortcutPlatform: () => 'darwin' as const +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (s: typeof appStoreMock.state) => unknown) => selector(appStoreMock.state) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useShortcutKeyComboDetails: () => [] +})) + +vi.mock('@/lib/terminal-quick-command-search', () => ({ + searchTerminalQuickCommands: (_cmds: unknown[], _q: string) => [], + getTerminalQuickCommandPickerValue: () => null +})) + +vi.mock('../../../../shared/terminal-quick-commands', () => ({ + isTerminalAgentQuickCommand: () => false, + getTerminalQuickCommandBody: () => '' +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentLabel: () => '', + AgentIcon: () => null +})) + +vi.mock('./TabBarQuickCommandItem', () => ({ + TabBarQuickCommandItem: () => null +})) + +vi.mock('@/lib/utils', () => ({ + cn: (...args: string[]) => args.filter(Boolean).join(' ') +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('lucide-react', () => ({ + ChevronDown: () => null, + Play: () => null, + Plus: () => null +})) + +vi.mock('@/components/ui/command', () => ({ + Command: () => null, + CommandEmpty: () => null, + CommandInput: () => null, + CommandList: () => null, + CommandSeparator: () => null +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: () => null, + DropdownMenuContent: () => null, + DropdownMenuTrigger: () => null +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: () => null, + TooltipContent: () => null, + TooltipTrigger: () => null +})) + +function makeProps() { + return { + repoCommands: [] as never[], + globalCommands: [] as never[], + mostRecent: null, + onAddCommand: vi.fn(), + onDeleteCommand: vi.fn(), + onEditCommand: vi.fn(), + onRunCommand: vi.fn() + } +} + +function makeKeyEvent(overrides: Partial = {}): KeyboardEvent { + return { + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + key: 'q', + code: 'KeyQ', + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + ...overrides + } as unknown as KeyboardEvent +} + +beforeEach(() => { + reactRuntime.states = [] + reactRuntime.index = 0 + reactRuntime.effects = [] + windowListeners.clear() + keybindingsMock.matchAction.mockClear() + keybindingsMock.matchAction.mockReturnValue(false) + appStoreMock.state.activeView = 'terminal' + appStoreMock.state.keybindings = {} + appStoreMock.state.settings.terminalShortcutPolicy = 'orca-first' + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, handler: (e: KeyboardEvent) => void) => { + windowListeners.set(type, handler) + }), + removeEventListener: vi.fn() + }) + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +}) + +describe('TabBarQuickCommandsMenu keyboard shortcut', () => { + it('registers a capturing keydown listener on mount', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + // Effect index 0 is the keyboard toggle effect. + reactRuntime.effects[0]() + + expect(window.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), { + capture: true + }) + expect(window.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), { + capture: true + }) + }) + + it('does not register keyboard listeners while the terminal workbench is hidden', async () => { + appStoreMock.state.activeView = 'settings' + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + expect(window.addEventListener).not.toHaveBeenCalledWith('keydown', expect.any(Function), { + capture: true + }) + expect(window.addEventListener).not.toHaveBeenCalledWith('keyup', expect.any(Function), { + capture: true + }) + }) + + it('toggles menuOpen to true when a matching key is pressed', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + keybindingsMock.matchAction.mockReturnValue(true) + const handler = windowListeners.get('keydown') + expect(handler).toBeDefined() + handler!(makeKeyEvent()) + + // menuOpen is useState index 0, initial false → toggled to true. + expect(reactRuntime.states[0]).toBe(true) + }) + + it('toggles menuOpen closed when pressed again', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + reactRuntime.effects[0]() + + keybindingsMock.matchAction.mockReturnValue(true) + windowListeners.get('keydown')!(makeKeyEvent()) + expect(reactRuntime.states[0]).toBe(true) + + // Simulate React re-running the effect after menuOpen changed to true, + // so the handler closes over the updated value. + reactRuntime.index = 0 + reactRuntime.effects = [] + TabBarQuickCommandsMenu(makeProps()) + reactRuntime.effects[0]() + + windowListeners.get('keydown')!(makeKeyEvent()) + expect(reactRuntime.states[0]).toBe(false) + }) + + it('does not toggle when the event is a repeat (key held down)', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + keybindingsMock.matchAction.mockReturnValue(true) + const handler = windowListeners.get('keydown')! + handler(makeKeyEvent({ repeat: true })) + + expect(reactRuntime.states[0]).toBe(false) + }) + + it('does not toggle when the key does not match the action', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + keybindingsMock.matchAction.mockReturnValue(false) + const handler = windowListeners.get('keydown')! + handler(makeKeyEvent()) + + expect(reactRuntime.states[0]).toBe(false) + }) + + it('prevents default and stops propagation for matching keys', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + keybindingsMock.matchAction.mockReturnValue(true) + const event = makeKeyEvent() + windowListeners.get('keydown')!(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopImmediatePropagation).toHaveBeenCalled() + }) + + it('passes terminal context and terminal-first policy to shortcut matching', async () => { + appStoreMock.state.settings.terminalShortcutPolicy = 'terminal-first' + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + const terminalTarget = { + classList: { contains: (className: string) => className === 'xterm-helper-textarea' }, + closest: () => null + } as unknown as EventTarget + const handler = windowListeners.get('keydown')! + handler(makeKeyEvent({ target: terminalTarget })) + + expect(keybindingsMock.matchAction).toHaveBeenCalledWith( + 'tab.openQuickCommandsMenu', + expect.objectContaining({ key: 'q', code: 'KeyQ' }), + 'darwin', + appStoreMock.state.keybindings, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + expect(reactRuntime.states[0]).toBe(false) + }) + + it('ignores shortcut presses while the shortcut recorder is active', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + const recorderTarget = { + closest: (selector: string) => (selector === '[data-shortcut-recorder-active]' ? {} : null) + } as unknown as EventTarget + const handler = windowListeners.get('keydown')! + handler(makeKeyEvent({ target: recorderTarget })) + + expect(keybindingsMock.matchAction).not.toHaveBeenCalled() + expect(reactRuntime.states[0]).toBe(false) + }) + + it('toggles from a matching double-tap binding in the renderer path', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + reactRuntime.effects[0]() + + keybindingsMock.matchAction.mockImplementation( + (_actionId, input: { doubleTapModifier?: string }) => input.doubleTapModifier === 'Shift' + ) + const keyDown = windowListeners.get('keydown')! + const keyUp = windowListeners.get('keyup')! + const firstDown = makeKeyEvent({ + key: 'Shift', + code: 'ShiftLeft', + metaKey: false, + shiftKey: true + }) + const firstUp = makeKeyEvent({ + key: 'Shift', + code: 'ShiftLeft', + metaKey: false, + shiftKey: true + }) + const secondDown = makeKeyEvent({ + key: 'Shift', + code: 'ShiftLeft', + metaKey: false, + shiftKey: true + }) + + keyDown(firstDown) + keyUp(firstUp) + keyDown(secondDown) + + expect(reactRuntime.states[0]).toBe(true) + expect(secondDown.preventDefault).toHaveBeenCalled() + expect(secondDown.stopImmediatePropagation).toHaveBeenCalled() + }) + + it('removes the listener when the effect is cleaned up', async () => { + reactRuntime.index = 0 + const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu') + TabBarQuickCommandsMenu(makeProps()) + + const cleanup = reactRuntime.effects[0]() + cleanup?.() + + expect(window.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), { + capture: true + }) + expect(window.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), { + capture: true + }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx index 7605861bb..1d1b616c6 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react' +import { ChevronDown, Play, Plus } from 'lucide-react' import { Command, CommandEmpty, CommandInput, - CommandItem, CommandList, CommandSeparator } from '@/components/ui/command' @@ -14,18 +13,22 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { getTerminalQuickCommandBody, isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import type { TerminalQuickCommand } from '../../../../shared/types' -import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { getAgentLabel } from '@/lib/agent-catalog' +import { TabBarQuickCommandItem } from './TabBarQuickCommandItem' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { getTerminalQuickCommandPickerValue, searchTerminalQuickCommands } from '@/lib/terminal-quick-command-search' +import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' +import { useTabBarQuickCommandsShortcut } from './tab-bar-quick-commands-shortcut' type TabBarQuickCommandsMenuProps = { repoCommands: readonly TerminalQuickCommand[] globalCommands: readonly TerminalQuickCommand[] @@ -35,6 +38,7 @@ type TabBarQuickCommandsMenuProps = { onEditCommand: (command: TerminalQuickCommand) => void onRunCommand: (command: TerminalQuickCommand) => void } + export function TabBarQuickCommandsMenu({ repoCommands, globalCommands, @@ -44,12 +48,17 @@ export function TabBarQuickCommandsMenu({ onEditCommand, onRunCommand }: TabBarQuickCommandsMenuProps): React.JSX.Element { + const openMenuShortcutCombos = useShortcutKeyComboDetails('tab.openQuickCommandsMenu') const [menuOpen, setMenuOpen] = useState(false) + const [moreCommandsTooltipOpen, setMoreCommandsTooltipOpen] = useState(false) const [query, setQuery] = useState('') const [commandValueOverride, setCommandValueOverride] = useState(null) const searchInputRef = useRef(null) const commandListRef = useRef(null) const focusFrameRef = useRef(null) + // Why: closing restores focus to the chevron for accessibility, but that + // focus restoration should not immediately reopen its tooltip. + const suppressMoreCommandsTooltipRef = useRef(false) const totalVisible = repoCommands.length + globalCommands.length const showSearch = totalVisible > 1 const filteredRepoCommands = useMemo( @@ -101,16 +110,36 @@ export function TabBarQuickCommandsMenu({ searchInput.setSelectionRange(end, end) }) }, [cancelFocusFrame]) - const handleOpenChange = (next: boolean): void => { - setMenuOpen(next) - if (next) { - setCommandValueOverride(null) + const handleMoreCommandsTooltipOpenChange = useCallback((next: boolean): void => { + if (next && suppressMoreCommandsTooltipRef.current) { return } - cancelFocusFrame() - setQuery('') - setCommandValueOverride(null) - } + setMoreCommandsTooltipOpen(next) + }, []) + const allowMoreCommandsTooltip = useCallback((): void => { + suppressMoreCommandsTooltipRef.current = false + }, []) + const handleOpenChange = useCallback( + (next: boolean): void => { + setMenuOpen(next) + if (next) { + suppressMoreCommandsTooltipRef.current = false + setMoreCommandsTooltipOpen(false) + setCommandValueOverride(null) + return + } + suppressMoreCommandsTooltipRef.current = true + setMoreCommandsTooltipOpen(false) + cancelFocusFrame() + setQuery('') + setCommandValueOverride(null) + }, + [cancelFocusFrame] + ) + const closeMenu = useCallback((): void => { + handleOpenChange(false) + }, [handleOpenChange]) + useTabBarQuickCommandsShortcut({ menuOpen, onOpenChange: handleOpenChange }) useEffect(() => { if (!menuOpen || !showSearch) { return @@ -122,10 +151,10 @@ export function TabBarQuickCommandsMenu({ }, [cancelFocusFrame, focusSearchInput, menuOpen, showSearch]) const runAndClose = useCallback( (command: TerminalQuickCommand): void => { - setMenuOpen(false) + closeMenu() onRunCommand(command) }, - [onRunCommand] + [closeMenu, onRunCommand] ) const handleSearchKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -170,72 +199,14 @@ export function TabBarQuickCommandsMenu({ }, [commandValue, filteredVisibleCommands, runAndClose, selectedCommand] ) + const moreCommandsLabel = translate( + 'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b', + 'More quick commands' + ) const splitButtonClass = 'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground' const innerButtonBase = 'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' - const renderItem = (command: TerminalQuickCommand): React.JSX.Element => ( - runAndClose(command)} - className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground" - > - {isTerminalAgentQuickCommand(command) ? ( - - - - ) : ( - - )} - - {command.label} - - {isTerminalAgentQuickCommand(command) - ? `${getAgentLabel(command.agent)}: ${command.prompt}` - : command.command} - - - - - - - - ) return (
@@ -288,21 +259,39 @@ export function TabBarQuickCommandsMenu({ - - - + + + + + + + + + {moreCommandsLabel} + {openMenuShortcutCombos.map((shortcut, index) => ( + + ))} + + + ) : null} - {filteredRepoCommands.map(renderItem)} + {filteredRepoCommands.map((command) => ( + runAndClose(command)} + onEdit={() => { + closeMenu() + onEditCommand(command) + }} + onDelete={() => { + closeMenu() + onDeleteCommand(command) + }} + /> + ))} {filteredRepoCommands.length > 0 && filteredGlobalCommands.length > 0 ? ( ) : null} - {filteredGlobalCommands.map(renderItem)} + {filteredGlobalCommands.map((command) => ( + runAndClose(command)} + onEdit={() => { + closeMenu() + onEditCommand(command) + }} + onDelete={() => { + closeMenu() + onDeleteCommand(command) + }} + /> + ))}