feat(ui): migrate all shortcut surfaces to the centralized registry

This commit is contained in:
Bohdan Triapitsyn
2026-08-26 10:59:04 +03:00
parent f1c3870909
commit 94f6b5fd38
24 changed files with 1057 additions and 904 deletions
+5 -21
View File
@@ -20,7 +20,7 @@ import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils'; import { useKeybind } from '@/hooks/useKeybind';
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
import { import {
getInjectedBootOutcome, getInjectedBootOutcome,
@@ -723,26 +723,10 @@ function App({ apis }: AppProps) {
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
React.useEffect(() => { useKeybind('toggle_memory_debug', () => {
if (embeddedSessionChat) { if (embeddedSessionChat) return false;
return; setShowMemoryDebug((previous) => !previous);
} });
const handleKeyDown = (e: KeyboardEvent) => {
const isDebugShortcut = hasModifier(e)
&& e.shiftKey
&& !e.altKey
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
if (isDebugShortcut) {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [embeddedSessionChat]);
React.useEffect(() => { React.useEffect(() => {
if (embeddedSessionChat) { if (embeddedSessionChat) {
@@ -141,6 +141,9 @@ and the send path reading the same grammar.
- `state/useDraftTarget.ts` — the draft can target a directory that does not - `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation. branch list, or the selector snaps back to the project root mid-creation.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
## Mobile ## Mobile
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -26,6 +27,7 @@ import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { useKeybind } from '@/hooks/useKeybind';
import type { Theme } from '@/types/theme'; import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths'; import { normalizePath } from '../attachments/filePaths';
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget'; import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
@@ -106,14 +108,48 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onDirectoryChange, onDirectoryChange,
theme, theme,
} = props; } = props;
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
setOpenPicker('project');
});
useKeybind('open_draft_worktree_picker', () => {
if (!showBranchSelector) return false;
worktreeTriggerRef.current?.focus();
setOpenPicker('worktree');
});
const handleProjectChange = (projectId: string) => {
onProjectChange(projectId);
setOpenPicker(null);
};
const handleDirectoryChange = (directory: string) => {
onDirectoryChange(directory);
setOpenPicker(null);
};
return ( return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5"> <div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select <Select
value={selectedProject.id} value={selectedProject.id}
onValueChange={onProjectChange} open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
> >
<SelectTrigger <SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm" size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
> >
@@ -123,9 +159,9 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
: <ProjectLabel project={selectedProject} theme={theme} />} : <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => ( {projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate"> <SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} /> <ProjectLabel project={project} theme={theme} />
</SelectItem> </SelectItem>
))} ))}
@@ -135,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{showBranchSelector ? ( {showBranchSelector ? (
<Select <Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''} value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
onValueChange={onDirectoryChange} open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
> >
<SelectTrigger <SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm" size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
> >
@@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')} {selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48"> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? ( {projectRootBranchOption ? (
<SelectGroup> <SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel> <SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate"> <SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{projectRootBranchOption.label} {projectRootBranchOption.label}
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
@@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</button> </button>
</div> </div>
{worktreeBranchOptions.map((option) => ( {worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate"> <SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{option.pending ? '⏳ ' : ''}{option.label} {option.pending ? '⏳ ' : ''}{option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
{selectedDirectory && !selectedBranchIsKnown ? ( {selectedDirectory && !selectedBranchIsKnown ? (
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate"> <SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
{selectedBranchLabel} {selectedBranchLabel}
</SelectItem> </SelectItem>
) : null} ) : null}
@@ -5,7 +5,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn, isMacOS } from '@/lib/utils'; import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
type FocusModeButtonProps = { type FocusModeButtonProps = {
footerIconButtonClass: string; footerIconButtonClass: string;
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n(); const { t } = useI18n();
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
const expandInputCombo = getEffectiveShortcutCombo(
'expand_input',
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
);
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
return ( return (
<Tooltip> <Tooltip>
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
<TooltipContent side="top" sideOffset={8}> <TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center"> <div className="flex flex-col gap-0.5 text-center">
<span>{t('chat.chatInput.focusMode.label')}</span> <span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60"> {shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects'; import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
interface TextSelectionMenuProps { interface TextSelectionMenuProps {
@@ -106,6 +107,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const openRafRef = React.useRef<number | null>(null); const openRafRef = React.useRef<number | null>(null);
const mouseUpTimeoutRef = React.useRef<number | null>(null); const mouseUpTimeoutRef = React.useRef<number | null>(null);
const isMenuVisibleRef = React.useRef(false); const isMenuVisibleRef = React.useRef(false);
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
const createSession = useSessionUIStore((state) => state.createSession); const createSession = useSessionUIStore((state) => state.createSession);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
@@ -156,6 +158,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
if (openRafRef.current !== null) { if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current); window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null; openRafRef.current = null;
@@ -169,6 +173,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const hideMenu = React.useCallback(() => { const hideMenu = React.useCallback(() => {
pendingSelectionRef.current = null; pendingSelectionRef.current = null;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
setCommentRects(null); setCommentRects(null);
if (!isMenuVisibleRef.current) { if (!isMenuVisibleRef.current) {
@@ -209,12 +215,30 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return Math.min(Math.max(anchorX, minX), maxX); return Math.min(Math.max(anchorX, minX), maxX);
}, []); }, []);
const addMarkdownToChat = React.useCallback((markdownText: string) => {
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
setPendingInputText(markdownBlock, 'append');
hideMenu();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [hideMenu, setPendingInputText]);
const showMenu = React.useCallback(() => { const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return; if (!pendingSelectionRef.current) return;
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show; const shouldAnimateIn = !position.show;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
addToChat: () => addMarkdownToChat(markdownText),
dismiss: hideMenu,
});
// Position menu above the selection // Position menu above the selection
const menuX = isMobile const menuX = isMobile
? rect.left + rect.width / 2 ? rect.left + rect.width / 2
@@ -241,7 +265,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
openRafRef.current = null; openRafRef.current = null;
}); });
} }
}, [getDesktopClampedX, isMobile, position.show]); }, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) { if (!position.show || isMobile || !menuRef.current) {
@@ -428,18 +452,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToChat = React.useCallback(() => { const handleAddToChat = React.useCallback(() => {
if (!selectedTextMarkdown) return; if (!selectedTextMarkdown) return;
addMarkdownToChat(selectedTextMarkdown);
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown); }, [addMarkdownToChat, selectedTextMarkdown]);
setPendingInputText(markdownBlock, 'append');
hideMenu();
// Clear selection
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
const handleOpenComment = React.useCallback(() => { const handleOpenComment = React.useCallback(() => {
if (!selectedTextMarkdown) return; if (!selectedTextMarkdown) return;
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
export interface InlineCommentInputProps { export interface InlineCommentInputProps {
initialText?: string; initialText?: string;
@@ -37,6 +38,7 @@ export function InlineCommentInput({
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText); const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const saveShortcut = formatShortcutForDisplay('mod+enter');
void isEditing; void isEditing;
const handleTextChange = (value: string) => { const handleTextChange = (value: string) => {
@@ -166,7 +168,9 @@ export function InlineCommentInput({
value={text} value={text}
onChange={(e) => handleTextChange(e.target.value)} onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} placeholder={isMobile
? t('inlineComment.input.placeholderShort')
: t('inlineComment.input.placeholder', { shortcut: saveShortcut })}
className={cn( className={cn(
'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60', 'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5' isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5'
+23 -46
View File
@@ -38,7 +38,8 @@ import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControl
import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { formatShortcutForDisplay, getEffectiveShortcutCombo, type ShortcutActionId } from '@/lib/shortcuts';
import { useKeybinds } from '@/hooks/useKeybind';
import { import {
} from '@/lib/quota/model-families'; } from '@/lib/quota/model-families';
@@ -256,7 +257,7 @@ type DesktopServicesMenuProps = {
isDesktopServicesOpen: boolean; isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
refreshCurrentInstanceLabel: () => Promise<void>; refreshCurrentInstanceLabel: () => Promise<void>;
shortcutLabel: (actionId: string) => string; shortcutLabel: (actionId: ShortcutActionId) => string;
remoteUpdateInfo: UpdateInfo | null; remoteUpdateInfo: UpdateInfo | null;
remoteUpdateChecking: boolean; remoteUpdateChecking: boolean;
remoteUpdateError: string | null; remoteUpdateError: string | null;
@@ -1445,7 +1446,7 @@ export const Header: React.FC = () => {
} }
}, [isDesktopApp]); }, [isDesktopApp]);
const shortcutLabel = React.useCallback((actionId: string) => { const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]); }, [shortcutOverrides]);
@@ -1461,51 +1462,27 @@ export const Header: React.FC = () => {
}, [isDesktopApp, t]); }, [isDesktopApp, t]);
useEffect(() => { useKeybinds({
const handleKeyDown = (e: KeyboardEvent) => { toggle_services_menu: () => {
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides); if (isDesktopServicesOpen) {
if (eventMatchesShortcut(e, toggleServicesCombo)) { setIsDesktopServicesOpen(false);
e.preventDefault();
if (isDesktopServicesOpen) {
setIsDesktopServicesOpen(false);
} else {
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
}
return; return;
} }
setIsDesktopServicesOpen(true);
// The desktop menu holds one destination now, so this shortcut opens it void refreshCurrentInstanceLabel();
// rather than cycling. The binding is kept: it is user-configurable and },
// silently dropping it would break existing setups. // The desktop menu holds one destination now, so this shortcut opens it
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); // rather than cycling. The binding is kept: it is user-configurable and
if (eventMatchesShortcut(e, cycleServicesCombo)) { // silently dropping it would break existing setups.
e.preventDefault(); cycle_services_tab: () => {
if (servicesTabs.length === 0) return; if (servicesTabs.length === 0) return false;
setIsDesktopServicesOpen(true); setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel(); void refreshCurrentInstanceLabel();
return; },
} toggle_context_plan: () => {
handleOpenContextPlan();
const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides); },
if (eventMatchesShortcut(e, toggleContextPlanCombo)) { });
e.preventDefault();
handleOpenContextPlan();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
shortcutOverrides,
isDesktopServicesOpen,
servicesTabs,
quotaResults.length,
fetchAllQuotas,
refreshCurrentInstanceLabel,
handleOpenContextPlan,
]);
const desktopSidebarActions = ( const desktopSidebarActions = (
<> <>
@@ -62,6 +62,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { TerminalShellOption } from '@/lib/api/types'; import type { TerminalShellOption } from '@/lib/api/types';
import { isTerminalShell } from '@/lib/terminalShell'; import { isTerminalShell } from '@/lib/terminalShell';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
interface Option<T extends string> { interface Option<T extends string> {
id: T; id: T;
@@ -1480,7 +1481,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
label={t('settings.openchamber.visual.field.terminalQuickKeys')} label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')} ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys" settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')} info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip', {
control: formatShortcutForDisplay('ctrl'),
alt: formatShortcutForDisplay('alt'),
})}
/> />
)} )}
</div> </div>
@@ -24,6 +24,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
import { import {
isFilesystemError, isFilesystemError,
type FilesystemErrorReason, type FilesystemErrorReason,
@@ -360,9 +361,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const hasHighlightedBrowseItem = Boolean( const hasHighlightedBrowseItem = Boolean(
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled)) highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
); );
const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) const submitModifierLabel = formatShortcutForDisplay('mod');
? '⌘'
: 'Ctrl';
const submitActionLabel = isAlreadyAdded const submitActionLabel = isAlreadyAdded
? t('directoryExplorerDialog.actions.alreadyAdded') ? t('directoryExplorerDialog.actions.alreadyAdded')
: isCloneMode : isCloneMode
@@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store'; import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems'; import {
findSwitcherItemAncestorIds,
useSwitcherItems,
type SwitcherItem,
} from '@/components/session/sidebar/shell/useSwitcherItems';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { formatSessionCompactDateLabel } from './sidebar/utils'; import { formatSessionCompactDateLabel } from './sidebar/utils';
@@ -22,6 +26,7 @@ import { cn } from '@/lib/utils';
type SecondaryMeta = SwitcherItem['secondaryMeta']; type SecondaryMeta = SwitcherItem['secondaryMeta'];
type SwitcherVariant = 'default' | 'compact'; type SwitcherVariant = 'default' | 'compact';
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
type SessionSwitcherDropdownProps = { type SessionSwitcherDropdownProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({
const setOpen = useUIStore((state) => state.setSessionDropdownOpen); const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
return ( return (
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}> <DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align={align} align={align}
@@ -69,7 +74,9 @@ type SwitcherContentProps = {
}; };
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement { function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
const items = useSwitcherItems(true, { scopeProjectId }); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true);
const items = useSwitcherItems(true, { scopeProjectId, currentSessionId });
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const { t } = useI18n(); const { t } = useI18n();
@@ -79,6 +86,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
}, [onSelect, openNewSessionDraft]); }, [onSelect, openNewSessionDraft]);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set()); const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const contentRef = React.useRef<HTMLDivElement>(null);
const initialFocusCompleteRef = React.useRef(false);
const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId;
const toggleParent = React.useCallback((sessionId: string) => { const toggleParent = React.useCallback((sessionId: string) => {
setExpandedParents((prev) => { setExpandedParents((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -91,10 +101,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
}); });
}, []); }, []);
React.useLayoutEffect(() => {
if (initialFocusCompleteRef.current || !initialTarget) return;
const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET
? []
: findSwitcherItemAncestorIds(items, initialTarget);
if (!ancestorIds) return;
if (ancestorIds.some((id) => !expandedParents.has(id))) {
setExpandedParents((previous) => new Set([...previous, ...ancestorIds]));
return;
}
const animationFrame = requestAnimationFrame(() => {
const item = Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[data-switcher-item-id]') ?? [],
).find((element) => element.dataset.switcherItemId === initialTarget);
if (!item) return;
item.focus();
item.scrollIntoView({ block: 'nearest' });
initialFocusCompleteRef.current = true;
});
return () => cancelAnimationFrame(animationFrame);
}, [expandedParents, initialTarget, items]);
return ( return (
<div className="max-h-[60vh] overflow-y-auto"> <div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
<div className="space-y-0.5"> <div className="space-y-0.5">
<BaseMenu.Item <BaseMenu.Item
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
onClick={handleNewSession} onClick={handleNewSession}
className={cn( className={cn(
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none', 'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
@@ -227,6 +263,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
handleSelect(); handleSelect();
}} }}
data-slot="session-switcher-item" data-slot="session-switcher-item"
data-switcher-item-id={session.id}
className={cn( className={cn(
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none', 'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover', 'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import {
findSwitcherItemAncestorIds,
selectSwitcherParents,
type SwitcherItem,
} from './useSwitcherItems';
const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({
id,
parentID: options.parentID,
time: options.archived ? { archived: Date.now() } : undefined,
projectId: options.projectId ?? 'project-a',
} as unknown as Session);
const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => (
selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId)
);
describe('session switcher initial selection', () => {
test('finds all local ancestors for a current child session', () => {
const items: SwitcherItem[] = [{
node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] },
projectId: 'project-a', groupDirectory: null, secondaryMeta: null,
}];
expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']);
expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull();
});
test('replaces the final recent slot with the current root and excludes invalid current sessions', () => {
const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`));
const child = session('child', { parentID: 'root-7' });
expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([
'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7',
]);
expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]);
expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
});
});
@@ -27,6 +27,7 @@ const MAX_PARENT_SESSIONS = 7;
type SwitcherItemsOptions = { type SwitcherItemsOptions = {
scopeProjectId?: string | null; scopeProjectId?: string | null;
currentSessionId?: string | null;
/** How many parent sessions to return (default 7 — the desktop dropdown). */ /** How many parent sessions to return (default 7 — the desktop dropdown). */
maxParents?: number; maxParents?: number;
}; };
@@ -46,8 +47,69 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
return segments[segments.length - 1] ?? null; return segments[segments.length - 1] ?? null;
}; };
export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => {
const visit = (node: SessionNode, ancestors: string[]): string[] | null => {
if (node.session.id === sessionId) return ancestors;
for (const child of node.children) {
const result = visit(child, [...ancestors, node.session.id]);
if (result) return result;
}
return null;
};
for (const item of items) {
const result = visit(item.node, []);
if (result) return result;
}
return null;
};
export const selectSwitcherParents = (
activeSessions: Session[],
pinnedSessionIds: Set<string>,
sessionOrderRanks: Map<string, number>,
scopeProjectId: string | null,
currentSessionId: string | null,
getProjectId: (session: Session) => string | null,
maxParents = MAX_PARENT_SESSIONS,
isExcluded?: (session: Session) => boolean,
): Session[] => {
const sessionsById = new Map(activeSessions.map((session) => [session.id, session]));
const isEligibleParent = (session: Session): boolean => {
if (session.time?.archived) return false;
if (isExcluded?.(session)) return false;
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
if ((session as Session & { parentID?: string | null }).parentID) return false;
return !scopeProjectId || getProjectId(session) === scopeProjectId;
};
const parents = activeSessions
.filter(isEligibleParent)
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null;
let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession;
const visited = new Set<string>();
while (currentRoot) {
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
const parentId = (currentRoot as Session & { parentID?: string | null }).parentID;
if (!parentId) break;
if (visited.has(parentId)) {
currentRoot = null;
break;
}
visited.add(parentId);
currentRoot = sessionsById.get(parentId) ?? null;
}
const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1;
if (currentRootIndex >= maxParents) {
return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!];
}
return parents.slice(0, maxParents);
};
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => { export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options; const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options;
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
@@ -116,19 +178,17 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
}); });
const parents = activeSessions const parents = selectSwitcherParents(
.filter((session) => !session.time?.archived) activeSessions,
pinnedSessionIds,
sessionOrderRanks,
scopeProjectId,
currentSessionId,
(session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null,
maxParents,
// btw forks stay hidden until promoted to a full session // btw forks stay hidden until promoted to a full session
.filter((session) => !isBtwSession(session)) (session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))),
.filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) );
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.filter((session) => {
if (!scopeProjectId) return true;
const directory = resolveGlobalSessionDirectory(session);
return findProjectForDirectory(directory)?.id === scopeProjectId;
})
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
.slice(0, maxParents);
const buildNode = (session: Session): SessionNode => { const buildNode = (session: Session): SessionNode => {
const childSessions = childrenByParent.get(session.id) ?? []; const childSessions = childrenByParent.get(session.id) ?? [];
@@ -158,7 +218,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
}, },
}; };
}); });
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); }, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
return items; return items;
}; };
@@ -3,6 +3,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { shortcutRegistry } from "@/lib/shortcuts";
import { handleDropdownNavigationKey } from "./dropdown-navigation";
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
type AsChildProps = { asChild?: boolean }; type AsChildProps = { asChild?: boolean };
@@ -34,11 +36,21 @@ function renderFromAsChild(asChild: boolean | undefined, children: React.ReactNo
return { children }; return { children };
} }
type DropdownMenuProps = React.ComponentProps<typeof BaseMenu.Root> & {
disableGlobalShortcuts?: boolean;
};
function DropdownMenu({ function DropdownMenu({
disableGlobalShortcuts = false,
open,
defaultOpen,
onOpenChange,
...props ...props
}: React.ComponentProps<typeof BaseMenu.Root>) { }: DropdownMenuProps) {
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null); const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null); const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
const isOpen = open ?? uncontrolledOpen;
const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({ const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({
portalContainer, portalContainer,
collisionBoundary, collisionBoundary,
@@ -46,9 +58,24 @@ function DropdownMenu({
setCollisionBoundary, setCollisionBoundary,
}), [collisionBoundary, portalContainer]); }), [collisionBoundary, portalContainer]);
React.useLayoutEffect(() => {
if (!disableGlobalShortcuts || !isOpen) return;
return shortcutRegistry.suspend();
}, [disableGlobalShortcuts, isOpen]);
const handleOpenChange: NonNullable<React.ComponentProps<typeof BaseMenu.Root>['onOpenChange']> = (nextOpen, eventDetails) => {
if (open === undefined) setUncontrolledOpen(nextOpen);
onOpenChange?.(nextOpen, eventDetails);
};
return ( return (
<DropdownPortalContext.Provider value={portalContextValue}> <DropdownPortalContext.Provider value={portalContextValue}>
<BaseMenu.Root {...props} /> <BaseMenu.Root
{...props}
defaultOpen={defaultOpen}
open={open}
onOpenChange={handleOpenChange}
/>
</DropdownPortalContext.Provider> </DropdownPortalContext.Provider>
) )
} }
@@ -116,11 +143,23 @@ function DropdownMenuContent({
style, style,
children, children,
onCloseAutoFocus, onCloseAutoFocus,
onKeyDown,
...props ...props
}: ContentProps) { }: ContentProps) {
const portalContext = React.useContext(DropdownPortalContext); const portalContext = React.useContext(DropdownPortalContext);
void onCloseAutoFocus void onCloseAutoFocus
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
onKeyDown?.(event);
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
};
return ( return (
<BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}> <BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}>
<BaseMenu.Positioner <BaseMenu.Positioner
@@ -143,6 +182,7 @@ function DropdownMenuContent({
className className
)} )}
{...props} {...props}
onKeyDown={handleKeyDown}
> >
{children} {children}
</BaseMenu.Popup> </BaseMenu.Popup>
@@ -2,7 +2,7 @@ import type React from 'react';
import { isIMECompositionEvent } from '@/lib/ime'; import { isIMECompositionEvent } from '@/lib/ime';
export function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null { function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
if (event.key.toLowerCase() === 'n') return 'ArrowDown'; if (event.key.toLowerCase() === 'n') return 'ArrowDown';
if (event.key.toLowerCase() === 'p') return 'ArrowUp'; if (event.key.toLowerCase() === 'p') return 'ArrowUp';
+46 -3
View File
@@ -8,6 +8,8 @@ import { cn } from "@/lib/utils"
import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger" import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { shortcutRegistry } from "@/lib/shortcuts";
import { handleDropdownNavigationKey } from "./dropdown-navigation";
type AsChildProps = { asChild?: boolean }; type AsChildProps = { asChild?: boolean };
type AsChildRenderProps = { type AsChildRenderProps = {
@@ -38,15 +40,22 @@ type SelectRootProps<Value extends string = string> = Omit<
value?: Value; value?: Value;
defaultValue?: Value; defaultValue?: Value;
onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void; onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void;
disableGlobalShortcuts?: boolean;
}; };
function Select<Value extends string = string>({ function Select<Value extends string = string>({
onValueChange, onValueChange,
modal = false, modal = false,
disableGlobalShortcuts = false,
open,
defaultOpen,
onOpenChange,
...props ...props
}: SelectRootProps<Value>) { }: SelectRootProps<Value>) {
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null); const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null); const [collisionBoundary, setCollisionBoundary] = React.useState<Element | null>(null);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
const isOpen = open ?? uncontrolledOpen;
const portalContextValue = React.useMemo<SelectPortalContextValue>(() => ({ const portalContextValue = React.useMemo<SelectPortalContextValue>(() => ({
portalContainer, portalContainer,
collisionBoundary, collisionBoundary,
@@ -63,9 +72,26 @@ function Select<Value extends string = string>({
[onValueChange] [onValueChange]
); );
React.useLayoutEffect(() => {
if (!disableGlobalShortcuts || !isOpen) return;
return shortcutRegistry.suspend();
}, [disableGlobalShortcuts, isOpen]);
const handleOpenChange: NonNullable<React.ComponentProps<typeof BaseSelect.Root>['onOpenChange']> = (nextOpen, eventDetails) => {
if (open === undefined) setUncontrolledOpen(nextOpen);
onOpenChange?.(nextOpen, eventDetails);
};
return ( return (
<SelectPortalContext.Provider value={portalContextValue}> <SelectPortalContext.Provider value={portalContextValue}>
<BaseSelect.Root {...props} modal={modal} onValueChange={handleValueChange} /> <BaseSelect.Root
{...props}
modal={modal}
open={open}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
/>
</SelectPortalContext.Provider> </SelectPortalContext.Provider>
) )
} }
@@ -184,12 +210,24 @@ function SelectContent({
align, align,
collisionAvoidance, collisionAvoidance,
constrainToMain = false, constrainToMain = false,
onKeyDown,
...props ...props
}: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) { }: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) {
const portalContext = React.useContext(SelectPortalContext); const portalContext = React.useContext(SelectPortalContext);
const alignItemWithTrigger = position === "item-aligned"; const alignItemWithTrigger = position === "item-aligned";
const portalContainer = portalContext?.portalContainer ?? null; const portalContainer = portalContext?.portalContainer ?? null;
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseSelect.Popup>['onKeyDown']> = (event) => {
onKeyDown?.(event);
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
};
return ( return (
<BaseSelect.Portal container={portalToBody ? undefined : portalContainer || undefined}> <BaseSelect.Portal container={portalToBody ? undefined : portalContainer || undefined}>
<BaseSelect.Positioner <BaseSelect.Positioner
@@ -214,6 +252,7 @@ function SelectContent({
className className
)} )}
{...props} {...props}
onKeyDown={handleKeyDown}
> >
<ScrollableOverlay <ScrollableOverlay
outerClassName={cn( outerClassName={cn(
@@ -253,13 +292,17 @@ function SelectLabel({
function SelectItem({ function SelectItem({
className, className,
children, children,
showSelectedBackground = true,
...props ...props
}: React.ComponentProps<typeof BaseSelect.Item>) { }: React.ComponentProps<typeof BaseSelect.Item> & {
showSelectedBackground?: boolean;
}) {
return ( return (
<BaseSelect.Item <BaseSelect.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover data-[selected]:bg-interactive-selection data-[selected]:text-interactive-selection-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 typography-ui-label outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 typography-ui-label outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
showSelectedBackground && "data-[selected]:bg-interactive-selection data-[selected]:text-interactive-selection-foreground",
className className
)} )}
{...props} {...props}
+37 -64
View File
@@ -45,7 +45,7 @@ import {
import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; import { cn, getRevealLabelKey } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers';
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { getRuntimeUrlResolver } from '@/lib/runtime-url';
@@ -75,7 +75,8 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes'; import { getDefaultTheme } from '@/lib/theme/themes';
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi'; import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
@@ -1032,7 +1033,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
@@ -1759,35 +1759,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setAutoSaveStatus('idle'); setAutoSaveStatus('idle');
}, [selectedFile?.path]); }, [selectedFile?.path]);
React.useEffect(() => { useKeybinds({
const handleKeyDown = (e: KeyboardEvent) => { save_file: (event) => {
if (!hasModifier(e)) { if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
return;
}
if (e.key.toLowerCase() === 's') { // Cancel pending auto-save because the explicit save should run immediately.
e.preventDefault(); if (autoSaveTimerRef.current) {
// Cancel pending auto-save; user wants immediate save clearTimeout(autoSaveTimerRef.current);
if (autoSaveTimerRef.current) { autoSaveTimerRef.current = null;
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (!isSaving) {
void saveDraft().then((saved) => {
if (!saved) return;
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}
} else if (e.key.toLowerCase() === 'f') {
e.preventDefault();
setIsSearchOpen(true);
} }
}; if (!isSaving) {
void saveDraft().then((saved) => {
window.addEventListener('keydown', handleKeyDown); if (!saved) return;
return () => window.removeEventListener('keydown', handleKeyDown); setAutoSaveStatus('saved');
}, [isSaving, saveDraft]); setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}
},
find_in_file: (event) => {
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
setIsSearchOpen(true);
},
});
const loadSelectedFile = React.useCallback(async (node: FileNode) => { const loadSelectedFile = React.useCallback(async (node: FileNode) => {
const loadId = activeFileLoadIdRef.current + 1; const loadId = activeFileLoadIdRef.current + 1;
@@ -2906,42 +2899,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}; };
}, [isMobile, nudgeEditorSelectionAboveKeyboard]); }, [isMobile, nudgeEditorSelectionAboveKeyboard]);
React.useEffect(() => { useKeybind('open_go_to_line', (event) => {
if (!canEdit || textViewMode !== 'edit' || isMobile) { if (!canEdit || textViewMode !== 'edit' || isMobile) {
return; return false;
} }
const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides); const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) return false;
if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false;
const handleKeyDown = (event: KeyboardEvent) => { const isEditorTarget = Boolean(target?.closest('.cm-editor'));
const target = event.target as Element | null; const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]'));
if (target?.closest('[role="dialog"]')) { if (isTypingTarget && !isEditorTarget) return false;
return;
}
const isEditorTarget = Boolean(target?.closest('.cm-editor')); setIsGoToLineOpen(true);
const isTypingTarget = Boolean( });
target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')
);
if (isTypingTarget && !isEditorTarget) {
return;
}
const activeElement = document.activeElement as Element | null;
const editorHasFocus = Boolean(activeElement?.closest('.cm-editor'));
if (!editorHasFocus) {
return;
}
if (eventMatchesShortcut(event, goToLineCombo)) {
event.preventDefault();
setIsGoToLineOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canEdit, isMobile, shortcutOverrides, textViewMode]);
const editorFontSize = useUIStore((state) => state.editorFontSize); const editorFontSize = useUIStore((state) => state.editorFontSize);
@@ -3196,6 +3168,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} }
const docked = layout === 'docked'; const docked = layout === 'docked';
const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file'));
const wrapperCls = docked const wrapperCls = docked
? 'pointer-events-auto flex flex-wrap items-center gap-1' ? 'pointer-events-auto flex flex-wrap items-center gap-1'
: 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm'; : 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm';
@@ -3225,14 +3198,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<Icon name="check" className="size-3.5" /> <Icon name="check" className="size-3.5" />
{t('filesView.editor.saved')} {t('filesView.editor.saved')}
</span> </span>
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }), ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }),
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void saveDraft()} onClick={() => void saveDraft()}
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent" className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` })} title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut })}
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })} aria-label={t('filesView.editor.saveAria', { shortcut: saveShortcut })}
> >
<Icon name="save-3" className="size-4" /> <Icon name="save-3" className="size-4" />
</Button> </Button>
@@ -1,5 +1,9 @@
import React from 'react'; import React from 'react';
import { cn, getModifierLabel } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory'; import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -187,6 +191,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsPageRaw = useUIStore((state) => state.settingsPage); const settingsPageRaw = useUIStore((state) => state.settingsPage);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage); const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
const settingsSlug = resolveSettingsSlug(settingsPageRaw); const settingsSlug = resolveSettingsSlug(settingsPageRaw);
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage); const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
@@ -728,7 +733,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
: showBackButton : showBackButton
? t('settings.view.actions.backToSettings') ? t('settings.view.actions.backToSettings')
: t('settings.view.actions.closeSettings'); : t('settings.view.actions.closeSettings');
const shortcutKey = getModifierLabel(); const openSettingsCombo = getEffectiveShortcutCombo(
'open_settings',
openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride },
);
const closeSettingsTitle = openSettingsCombo
? t('settings.view.actions.closeSettingsWithShortcut', {
shortcut: formatShortcutForDisplay(openSettingsCombo),
})
: t('settings.view.actions.closeSettings');
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => { const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
if (typeof window === 'undefined' || runtimeCtx.isVSCode) { if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
@@ -1077,7 +1090,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')} aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} title={closeSettingsTitle}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
> >
<Icon name="close" className="h-5 w-5" /> <Icon name="close" className="h-5 w-5" />
@@ -1105,7 +1118,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')} aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} title={closeSettingsTitle}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
> >
<Icon name="close" className="h-5 w-5" /> <Icon name="close" className="h-5 w-5" />
@@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions'; import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput'; import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
type TerminalViewProps = { type TerminalViewProps = {
visible?: boolean; visible?: boolean;
@@ -968,7 +969,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onClick={() => handleModifierToggle('ctrl')} onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled} disabled={quickKeysDisabled}
> >
<span className="text-xs font-medium">{t('terminalView.quickKeys.controlLabel')}</span> <span className="text-xs font-medium">{formatShortcutForDisplay('ctrl')}</span>
<span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span> <span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span>
</Button> </Button>
<Button <Button
@@ -981,7 +982,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onClick={() => handleModifierToggle('alt')} onClick={() => handleModifierToggle('alt')}
disabled={quickKeysDisabled} disabled={quickKeysDisabled}
> >
<span className="text-xs font-medium">{t('terminalView.quickKeys.altLabel')}</span> <span className="text-xs font-medium">{formatShortcutForDisplay('alt')}</span>
<span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span> <span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span>
</Button> </Button>
<Button <Button
@@ -15,3 +15,10 @@ export function shouldStopDropdownImeEscape(
&& event.key === 'Escape' && event.key === 'Escape'
&& (event.isComposing || event.keyCode === 229); && (event.isComposing || event.keyCode === 229);
} }
export function isEditableEventTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
}
File diff suppressed because it is too large Load Diff
@@ -1,97 +1,130 @@
import React from 'react'; import React from 'react';
import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { ShortcutDispatcher, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useKeybinds } from './useKeybind';
import { isEditableEventTarget } from './keyboard-shortcut-dom';
export const useMiniChatKeyboardShortcuts = () => { export const useMiniChatKeyboardShortcuts = () => {
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const dispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
if (!dispatcherRef.current) {
dispatcherRef.current = new ShortcutDispatcher({
registry: shortcutRegistry,
getBinding: (actionId) => getEffectiveShortcutCombo(
actionId,
useUIStore.getState().shortcutOverrides,
),
});
}
const dispatcher = dispatcherRef.current;
const cycleFavoriteModel = (delta: number): boolean | void => {
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) return false;
const {
currentProviderId,
currentModelId,
setProvider,
setModel,
} = useConfigStore.getState();
const currentIndex = favoriteModels.findIndex(
(favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId,
);
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
};
useKeybinds({
focus_input: () => {
focusChatInput();
},
new_mini_chat: () => {
if (!canUseElectronDesktopIPC()) return false;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: '',
projectId: null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
},
new_chat: () => {
const sessionState = useSessionUIStore.getState();
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
? { directoryOverride: sessionState.currentSessionDirectory }
: undefined);
focusChatInput();
},
open_model_selector: () => {
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
},
cycle_thinking_variant: () => {
const configState = useConfigStore.getState();
if (configState.getCurrentModelVariants().length === 0) return false;
configState.cycleCurrentVariant();
const sessionId = useSessionUIStore.getState().currentSessionId;
const {
currentVariant,
currentAgentName,
currentProviderId,
currentModelId,
} = useConfigStore.getState();
if (sessionId && currentAgentName && currentProviderId && currentModelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(
sessionId,
currentAgentName,
currentProviderId,
currentModelId,
currentVariant,
);
}
},
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
});
React.useEffect(() => { React.useEffect(() => {
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => {
if (!dispatcher.hasActivePrefix()) return;
const handleKeyDown = (event: KeyboardEvent) => { // An unmodified completion key typed into an editable target is only a
if (eventMatchesShortcut(event, combo('focus_input'))) { // deliberate sequence when the prefix was armed from that same target;
event.preventDefault(); // otherwise it is regular typing and must not be swallowed.
focusChatInput(); if (
!event.ctrlKey && !event.metaKey && !event.altKey
&& isEditableEventTarget(event.target)
&& dispatcher.getActivePrefixTarget() !== event.target
) {
dispatcher.clear();
return; return;
} }
if (dispatcher.dispatchActivePrefix(event)) {
if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) {
event.preventDefault(); event.preventDefault();
void invokeDesktop('desktop_open_draft_mini_chat_window', { event.stopPropagation();
directory: '',
projectId: null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
return;
}
if (eventMatchesShortcut(event, combo('new_chat'))) {
event.preventDefault();
const sessionState = useSessionUIStore.getState();
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
? { directoryOverride: sessionState.currentSessionDirectory }
: undefined);
focusChatInput();
return;
}
if (eventMatchesShortcut(event, combo('open_model_selector'))) {
event.preventDefault();
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
return;
}
if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) {
const configState = useConfigStore.getState();
const variants = configState.getCurrentModelVariants();
if (variants.length === 0) {
return;
}
event.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;
}
const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward'));
const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward'));
if (cyclesForward || cyclesBackward) {
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) {
return;
}
event.preventDefault();
const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState();
const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId);
const delta = cyclesForward ? 1 : -1;
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
} }
}; };
const handleKeyDown = (event: KeyboardEvent) => {
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
if (dispatcher.dispatch(event)) event.preventDefault();
};
const handleBlur = () => dispatcher.handleBlur();
window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true);
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); window.addEventListener('blur', handleBlur);
}, [openNewSessionDraft, shortcutOverrides]); return () => {
window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleBlur);
};
}, [dispatcher]);
}; };
+57
View File
@@ -8,9 +8,66 @@ import {
} from '@/components/chat/message/selectionMarkdown'; } from '@/components/chat/message/selectionMarkdown';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { shortcutRegistry } from '@/lib/shortcuts';
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]'; const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
interface ActiveSelectionToolbarActions {
addToChat: () => void;
dismiss: () => void;
}
interface ActiveSelectionToolbarRegistration extends ActiveSelectionToolbarActions {
resumeGlobalShortcuts: () => void;
}
const activeSelectionToolbarRegistrations: ActiveSelectionToolbarRegistration[] = [];
let activeSelectionToolbarVersion = 0;
const releaseSelectionToolbar = (registration: ActiveSelectionToolbarRegistration): void => {
const index = activeSelectionToolbarRegistrations.indexOf(registration);
if (index === -1) return;
activeSelectionToolbarRegistrations.splice(index, 1);
registration.resumeGlobalShortcuts();
activeSelectionToolbarVersion += 1;
};
export const registerActiveSelectionToolbar = (
actions: ActiveSelectionToolbarActions,
): (() => void) => {
const registration: ActiveSelectionToolbarRegistration = {
...actions,
resumeGlobalShortcuts: shortcutRegistry.suspend(),
};
activeSelectionToolbarRegistrations.push(registration);
activeSelectionToolbarVersion += 1;
return () => releaseSelectionToolbar(registration);
};
export const hasActiveSelectionToolbar = (): boolean => activeSelectionToolbarRegistrations.length > 0;
export const getActiveSelectionToolbarVersion = (): number => activeSelectionToolbarVersion;
export const invokeActiveSelectionAddToChat = (): boolean => {
const registration = activeSelectionToolbarRegistrations.at(-1);
if (!registration) return false;
releaseSelectionToolbar(registration);
registration.addToChat();
return true;
};
export const dismissActiveSelectionToolbar = (): boolean => {
const registration = activeSelectionToolbarRegistrations.at(-1);
if (!registration) return false;
releaseSelectionToolbar(registration);
registration.dismiss();
return true;
};
const isInsideChatComposer = (node: Node | null): boolean => { const isInsideChatComposer = (node: Node | null): boolean => {
if (!node) { if (!node) {
return false; return false;
+6 -6
View File
@@ -108,13 +108,13 @@ describe('shortcut defaults', () => {
list.push(action.id); list.push(action.id);
byBinding.set(combo, list); byBinding.set(combo, list);
} }
for (const [combo, ids] of byBinding) { const conflicts = [...byBinding.entries()]
if (ids.length <= 1) continue; .filter(([, ids]) => ids.length > 1)
const whitelisted = RUNTIME_EXCLUSIVE_BINDING_PAIRS.some( .filter(([, ids]) => !RUNTIME_EXCLUSIVE_BINDING_PAIRS.some(
(pair) => ids.every((id) => pair.has(id)), (pair) => ids.every((id) => pair.has(id)),
); ))
expect(whitelisted, `default binding "${combo}" shared by ${ids.join(', ')}`).toBe(true); .map(([combo, ids]) => `"${combo}" shared by ${ids.join(', ')}`);
} expect(conflicts).toEqual([]);
}); });
test('overrides recorded under the flat-file era still resolve', () => { test('overrides recorded under the flat-file era still resolve', () => {
-19
View File
@@ -1,6 +1,5 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { isDesktopShell } from "@/lib/desktop";
import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch"; import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch";
import type { I18nKey } from "@/lib/i18n"; import type { I18nKey } from "@/lib/i18n";
@@ -28,24 +27,6 @@ export const getRevealLabelKey = (): I18nKey => {
return 'common.revealPath.fileManager'; return 'common.revealPath.fileManager';
}; };
/**
* Checks if the platform-appropriate modifier key is pressed.
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
* Browser intercepts Cmd shortcuts, so we only use Cmd in the desktop app.
*/
export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
return isMacOS() && isDesktopShell() ? e.metaKey : e.ctrlKey;
};
/**
* Returns the platform-appropriate modifier key label.
* On macOS desktop app: "⌘", on other platforms or web: "Ctrl"
* Browser intercepts Cmd shortcuts, so we only show Cmd in the desktop app.
*/
export const getModifierLabel = (): string => {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
};
export const truncatePathMiddle = ( export const truncatePathMiddle = (
value: string, value: string,
options?: { maxLength?: number } options?: { maxLength?: number }