## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
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 { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
|
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
|
|
|
export const useKeyboardShortcuts = () => {
|
|
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
|
const {
|
|
toggleCommandPalette,
|
|
toggleHelpDialog,
|
|
toggleSidebar,
|
|
toggleRightSidebar,
|
|
setRightSidebarOpen,
|
|
setRightSidebarTab,
|
|
toggleBottomTerminal,
|
|
setBottomTerminalExpanded,
|
|
isMobile,
|
|
setSessionSwitcherOpen,
|
|
setActiveMainTab,
|
|
setSettingsDialogOpen,
|
|
setModelSelectorOpen,
|
|
toggleExpandedInput,
|
|
shortcutOverrides,
|
|
} = useUIStore();
|
|
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 handleKeyDown = (e: KeyboardEvent) => {
|
|
if (eventMatchesShortcut(e, combo('open_command_palette'))) {
|
|
e.preventDefault();
|
|
toggleCommandPalette();
|
|
return;
|
|
}
|
|
|
|
if (eventMatchesShortcut(e, combo('open_status'))) {
|
|
e.preventDefault();
|
|
void showOpenCodeStatus();
|
|
return;
|
|
}
|
|
|
|
if (eventMatchesShortcut(e, combo('open_help'))) {
|
|
e.preventDefault();
|
|
toggleHelpDialog();
|
|
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_timeline'))) {
|
|
e.preventDefault();
|
|
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
|
setTimelineDialogOpen(!isTimelineDialogOpen);
|
|
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;
|
|
}
|
|
|
|
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'] 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 sessionState = useSessionStore.getState();
|
|
const sessionId = sessionState.currentSessionId;
|
|
const agentName = useConfigStore.getState().currentAgentName;
|
|
const providerId = useConfigStore.getState().currentProviderId;
|
|
const modelId = useConfigStore.getState().currentModelId;
|
|
|
|
if (sessionId && agentName && providerId && modelId) {
|
|
sessionState.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"]'));
|
|
|
|
if (isInsideDialog || isSettingsMounted) {
|
|
resetAbortPriming();
|
|
return;
|
|
}
|
|
|
|
const {
|
|
isSettingsDialogOpen,
|
|
isCommandPaletteOpen,
|
|
isHelpDialogOpen,
|
|
isSessionSwitcherOpen,
|
|
isAboutDialogOpen,
|
|
isMultiRunLauncherOpen,
|
|
isImagePreviewOpen,
|
|
activeMainTab,
|
|
} = useUIStore.getState();
|
|
|
|
// If settings is open, close it
|
|
if (isSettingsDialogOpen) {
|
|
e.preventDefault();
|
|
setSettingsDialogOpen(false);
|
|
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 || undefined);
|
|
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', handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [
|
|
openNewSessionDraft,
|
|
abortCurrentOperation,
|
|
toggleCommandPalette,
|
|
toggleHelpDialog,
|
|
toggleSidebar,
|
|
toggleRightSidebar,
|
|
setRightSidebarOpen,
|
|
setRightSidebarTab,
|
|
toggleBottomTerminal,
|
|
setBottomTerminalExpanded,
|
|
isMobile,
|
|
setSessionSwitcherOpen,
|
|
setActiveMainTab,
|
|
setSettingsDialogOpen,
|
|
setModelSelectorOpen,
|
|
toggleExpandedInput,
|
|
setThemeMode,
|
|
working,
|
|
armAbortPrompt,
|
|
resetAbortPriming,
|
|
currentSessionId,
|
|
shortcutOverrides,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
resetAbortPriming();
|
|
};
|
|
}, [resetAbortPriming]);
|
|
};
|