Files
openchamber/packages/ui/src/hooks/useKeyboardShortcuts.ts
T
Bohdan Triapitsyn e65739d900 fix: prevent double-Esc abort while dropdowns are open
Keeps Escape focused on closing active dropdowns first
Avoids accidental session aborts from menu interactions
2026-06-03 23:29:52 +03:00

598 lines
20 KiB
TypeScript

import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useUIStore } from '@/stores/useUIStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
export const useKeyboardShortcuts = () => {
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const abortCurrentOperation = sessionActions.abortCurrentOperation;;
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
const isMobile = useUIStore((s) => s.isMobile);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen);
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const activeProject = useProjectsStore((s) => s.getActiveProject());
const { themeMode, setThemeMode } = useThemeSystem();
const { working } = useAssistantStatus();
const abortPrimedUntilRef = React.useRef<number | null>(null);
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const themeModeRef = React.useRef(themeMode);
React.useEffect(() => {
themeModeRef.current = themeMode;
}, [themeMode]);
const resetAbortPriming = React.useCallback(() => {
if (abortPrimedTimeoutRef.current) {
clearTimeout(abortPrimedTimeoutRef.current);
abortPrimedTimeoutRef.current = null;
}
abortPrimedUntilRef.current = null;
clearAbortPrompt();
}, [clearAbortPrompt]);
React.useEffect(() => {
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
const isTerminalEventTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) {
return false;
}
return Boolean(
target.closest('.terminal-viewport-container') ||
target.getAttribute('data-terminal-hidden-input') === 'true'
);
};
const dropdownTargetSelector = [
'[data-slot="dropdown-menu-content"]',
'[data-slot="select-content"]',
'[role="combobox"]',
'[role="listbox"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="option"]',
'[data-radix-popper-content-wrapper]',
].join(',');
const isDropdownEventTarget = (target: EventTarget | null) => {
return target instanceof Element && Boolean(target.closest(dropdownTargetSelector));
};
const hasOpenDropdown = () => {
const openDropdowns = document.querySelectorAll<HTMLElement>(
'[data-slot="dropdown-menu-content"], [data-slot="select-content"], [role="listbox"], [role="menu"], [data-radix-popper-content-wrapper]'
);
return Array.from(openDropdowns).some((element) => element.getClientRects().length > 0);
};
const handleTerminalShortcutCapture = (e: KeyboardEvent) => {
if (!isTerminalEventTarget(e.target)) {
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
e.stopPropagation();
toggleBottomTerminal();
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) {
const { isMobile, isBottomTerminalExpanded } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
e.stopPropagation();
setBottomTerminalExpanded(!isBottomTerminalExpanded);
return;
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (isTerminalEventTarget(e.target)) {
return;
}
const isChatInputTarget = (target: EventTarget | null) => {
return target instanceof HTMLTextAreaElement && target.getAttribute('data-chat-input') === 'true';
};
if (eventMatchesShortcut(e, combo('open_command_palette'))) {
e.preventDefault();
toggleCommandPalette();
return;
}
if (eventMatchesShortcut(e, combo('open_timeline_dialog'))) {
e.preventDefault();
setTimelineDialogOpen(true);
return;
}
if (eventMatchesShortcut(e, combo('open_status'))) {
e.preventDefault();
void showOpenCodeStatus();
return;
}
if (eventMatchesShortcut(e, combo('open_help'))) {
e.preventDefault();
toggleHelpDialog();
return;
}
if (canUseElectronDesktopIPC() && eventMatchesShortcut(e, combo('new_mini_chat'))) {
e.preventDefault();
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDirectory || activeProject?.path || '',
projectId: activeProject?.id ?? null,
}).catch((error) => {
console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error);
});
return;
}
const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat'));
const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree'));
if (matchedNewSessionShortcut || matchedWorktreeShortcut) {
e.preventDefault();
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
if (!isVSCodeRuntime() && matchedWorktreeShortcut) {
createWorktreeSession();
return;
}
openNewSessionDraft();
return;
}
if (eventMatchesShortcut(e, combo('cycle_theme'))) {
e.preventDefault();
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const activeElement = document.activeElement as HTMLElement | null;
const currentIndex = modes.indexOf(themeModeRef.current);
const nextIndex = (currentIndex + 1) % modes.length;
setThemeMode(modes[nextIndex]);
requestAnimationFrame(() => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return;
}
if (!document.hasFocus()) {
window.focus();
}
if (activeElement && document.contains(activeElement)) {
activeElement.focus({ preventScroll: true });
}
});
return;
}
if (eventMatchesShortcut(e, combo('open_settings'))) {
e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState();
setSettingsDialogOpen(!isSettingsDialogOpen);
return;
}
if (eventMatchesShortcut(e, combo('toggle_sidebar'))) {
e.preventDefault();
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
if (isMobile) {
setSessionSwitcherOpen(!isSessionSwitcherOpen);
} else {
toggleSidebar();
}
return;
}
if (eventMatchesShortcut(e, combo('focus_input'))) {
e.preventDefault();
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
return;
}
const cycleAgentCombo = combo('cycle_agent');
const cycleAgentBackwardCombo = cycleAgentCombo && !cycleAgentCombo.includes('shift')
? normalizeCombo(`shift+${cycleAgentCombo}`)
: '';
const cycleAgentDirection = cycleAgentBackwardCombo && eventMatchesShortcut(e, cycleAgentBackwardCombo)
? -1
: eventMatchesShortcut(e, cycleAgentCombo)
? 1
: 0;
if (cycleAgentDirection !== 0) {
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
} = useUIStore.getState();
const hasOverlay = isSettingsDialogOpen || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
if (hasOverlay || activeMainTab !== 'chat' || !isChatInputTarget(e.target)) {
return;
}
const configState = useConfigStore.getState();
const nextAgentName = getCycledPrimaryAgentName(
configState.getVisibleAgents(),
configState.currentAgentName,
cycleAgentDirection,
);
if (!nextAgentName) {
return;
}
e.preventDefault();
configState.setAgent(nextAgentName);
useUIStore.getState().addRecentAgent(nextAgentName);
const sessionId = useSessionUIStore.getState().currentSessionId;
if (sessionId) {
useSelectionStore.getState().saveSessionAgentSelection(sessionId, nextAgentName);
}
return;
}
if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
toggleRightSidebar();
return;
}
if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
setRightSidebarOpen(true);
setRightSidebarTab('git');
return;
}
if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
setRightSidebarOpen(true);
setRightSidebarTab('files');
return;
}
if (eventMatchesShortcut(e, combo('cycle_right_sidebar_tab'))) {
const { isMobile, rightSidebarTab } = useUIStore.getState();
if (isMobile) {
return;
}
const tabs = ['git', 'files', 'context'] as const;
const currentIndex = tabs.indexOf(rightSidebarTab);
const nextTab = tabs[(currentIndex + 1) % tabs.length];
e.preventDefault();
setRightSidebarOpen(true);
setRightSidebarTab(nextTab);
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
const { isMobile } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
toggleBottomTerminal();
return;
}
if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) {
const { isMobile, isBottomTerminalExpanded } = useUIStore.getState();
if (isMobile) {
return;
}
e.preventDefault();
setBottomTerminalExpanded(!isBottomTerminalExpanded);
return;
}
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
isModelSelectorOpen,
} = useUIStore.getState();
// Skip if settings open
if (isSettingsDialogOpen) {
return;
}
// Skip if any overlay open or not on chat tab
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive) {
return;
}
e.preventDefault();
setModelSelectorOpen(!isModelSelectorOpen);
return;
}
// Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M)
if (eventMatchesShortcut(e, combo('cycle_thinking_variant'))) {
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
} = useUIStore.getState();
if (isSettingsDialogOpen) {
return;
}
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive) {
return;
}
const configState = useConfigStore.getState();
const variants = configState.getCurrentModelVariants();
if (variants.length === 0) {
return;
}
e.preventDefault();
configState.cycleCurrentVariant();
const nextVariant = useConfigStore.getState().currentVariant;
const sessionId = useSessionUIStore.getState().currentSessionId;
const agentName = useConfigStore.getState().currentAgentName;
const providerId = useConfigStore.getState().currentProviderId;
const modelId = useConfigStore.getState().currentModelId;
if (sessionId && agentName && providerId && modelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
}
return;
}
// Ctrl+] / Ctrl+[: Cycle through starred models (same gating as Shift+M)
if (
eventMatchesShortcut(e, combo('cycle_favorite_model_forward')) ||
eventMatchesShortcut(e, combo('cycle_favorite_model_backward'))
) {
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
favoriteModels,
addRecentModel,
} = useUIStore.getState();
if (isSettingsDialogOpen) {
return;
}
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive || favoriteModels.length === 0) {
return;
}
e.preventDefault();
const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState();
const len = favoriteModels.length;
const currentIdx = favoriteModels.findIndex(
(f) => f.providerID === currentProviderId && f.modelID === currentModelId,
);
const delta = eventMatchesShortcut(e, combo('cycle_favorite_model_forward')) ? 1 : -1;
const next = favoriteModels[(currentIdx + delta + len) % len];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
return;
}
if (eventMatchesShortcut(e, combo('expand_input'))) {
if (isMobile) {
return;
}
e.preventDefault();
toggleExpandedInput();
return;
}
if (e.key === 'Escape') {
const target = e.target as Element | null;
const isInsideDialog = Boolean(target?.closest('[role="dialog"]'));
const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]'));
const isInsideTerminal = Boolean(
target?.closest('.terminal-viewport-container') ||
target?.getAttribute('data-terminal-hidden-input') === 'true'
);
const hasDropdownInteraction = isDropdownEventTarget(target) || hasOpenDropdown();
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
isMultiRunLauncherOpen,
isImagePreviewOpen,
activeMainTab,
} = useUIStore.getState();
if (isInsideDialog || isInsideTerminal || hasDropdownInteraction) {
resetAbortPriming();
return;
}
// If settings is open, close it
if (isSettingsDialogOpen) {
e.preventDefault();
setSettingsDialogOpen(false);
resetAbortPriming();
return;
}
if (isSettingsMounted) {
resetAbortPriming();
return;
}
// Check if any overlay is open or not on chat tab - don't process abort
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive) {
resetAbortPriming();
return;
}
// Double-ESC abort logic - only when on chat tab with no overlays
const sessionId = currentSessionId;
const canAbortNow = working.canAbort && Boolean(sessionId);
if (!canAbortNow) {
resetAbortPriming();
return;
}
const now = Date.now();
const primedUntil = abortPrimedUntilRef.current;
if (primedUntil && now < primedUntil) {
e.preventDefault();
resetAbortPriming();
void abortCurrentOperation(sessionId ?? '');
return;
}
e.preventDefault();
const expiresAt = armAbortPrompt(3000) ?? now + 3000;
abortPrimedUntilRef.current = expiresAt;
if (abortPrimedTimeoutRef.current) {
clearTimeout(abortPrimedTimeoutRef.current);
}
const delay = Math.max(expiresAt - now, 0);
abortPrimedTimeoutRef.current = setTimeout(() => {
if (abortPrimedUntilRef.current && Date.now() >= abortPrimedUntilRef.current) {
resetAbortPriming();
}
}, delay || 0);
return;
}
};
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
window.removeEventListener('keydown', handleKeyDown);
};
}, [
openNewSessionDraft,
abortCurrentOperation,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
toggleRightSidebar,
setRightSidebarOpen,
setRightSidebarTab,
toggleBottomTerminal,
setBottomTerminalExpanded,
isMobile,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
setModelSelectorOpen,
setTimelineDialogOpen,
toggleExpandedInput,
setThemeMode,
working,
armAbortPrompt,
resetAbortPriming,
currentSessionId,
currentDirectory,
activeProject?.id,
activeProject?.path,
shortcutOverrides,
]);
React.useEffect(() => {
return () => {
resetAbortPriming();
};
}, [resetAbortPriming]);
};