feat(ui): migrate all shortcut surfaces to the centralized registry
This commit is contained in:
+5
-21
@@ -20,7 +20,7 @@ import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
|
||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
|
||||
import {
|
||||
getInjectedBootOutcome,
|
||||
@@ -723,26 +723,10 @@ function App({ apis }: AppProps) {
|
||||
|
||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
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]);
|
||||
useKeybind('toggle_memory_debug', () => {
|
||||
if (embeddedSessionChat) return false;
|
||||
setShowMemoryDebug((previous) => !previous);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
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
|
||||
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.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,6 +27,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
|
||||
@@ -106,14 +108,48 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
onDirectoryChange,
|
||||
theme,
|
||||
} = 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 (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedProject.id}
|
||||
onValueChange={onProjectChange}
|
||||
open={openPicker === 'project'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
|
||||
onValueChange={handleProjectChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={projectTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
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"
|
||||
>
|
||||
@@ -123,9 +159,9 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
|
||||
{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} />
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -135,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{showBranchSelector ? (
|
||||
<Select
|
||||
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
|
||||
onValueChange={onDirectoryChange}
|
||||
open={openPicker === 'worktree'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
|
||||
onValueChange={handleDirectoryChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={worktreeTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
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"
|
||||
>
|
||||
@@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||
</SelectValue>
|
||||
</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 ? (
|
||||
<SelectGroup>
|
||||
<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}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</button>
|
||||
</div>
|
||||
{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}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{selectedDirectory && !selectedBranchIsKnown ? (
|
||||
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
|
||||
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{selectedBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,12 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
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 = {
|
||||
footerIconButtonClass: string;
|
||||
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
|
||||
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
|
||||
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
|
||||
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 (
|
||||
<Tooltip>
|
||||
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<div className="flex flex-col gap-0.5 text-center">
|
||||
<span>{t('chat.chatInput.focusMode.label')}</span>
|
||||
<span className="font-mono opacity-60">
|
||||
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
||||
</span>
|
||||
{shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
|
||||
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
@@ -106,6 +107,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const openRafRef = React.useRef<number | null>(null);
|
||||
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
@@ -156,6 +158,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
if (openRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(openRafRef.current);
|
||||
openRafRef.current = null;
|
||||
@@ -169,6 +173,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
pendingSelectionRef.current = null;
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
setCommentRects(null);
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
@@ -209,12 +215,30 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
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(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
|
||||
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
|
||||
addToChat: () => addMarkdownToChat(markdownText),
|
||||
dismiss: hideMenu,
|
||||
});
|
||||
|
||||
// Position menu above the selection
|
||||
const menuX = isMobile
|
||||
? rect.left + rect.width / 2
|
||||
@@ -241,7 +265,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
openRafRef.current = null;
|
||||
});
|
||||
}
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
}, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!position.show || isMobile || !menuRef.current) {
|
||||
@@ -428,18 +452,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const handleAddToChat = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
addMarkdownToChat(selectedTextMarkdown);
|
||||
}, [addMarkdownToChat, selectedTextMarkdown]);
|
||||
|
||||
const handleOpenComment = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
export interface InlineCommentInputProps {
|
||||
initialText?: string;
|
||||
@@ -37,6 +38,7 @@ export function InlineCommentInput({
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const [text, setText] = React.useState(initialText);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const saveShortcut = formatShortcutForDisplay('mod+enter');
|
||||
void isEditing;
|
||||
|
||||
const handleTextChange = (value: string) => {
|
||||
@@ -166,7 +168,9 @@ export function InlineCommentInput({
|
||||
value={text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
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(
|
||||
'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'
|
||||
|
||||
@@ -38,7 +38,8 @@ import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControl
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
|
||||
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 {
|
||||
} from '@/lib/quota/model-families';
|
||||
|
||||
@@ -256,7 +257,7 @@ type DesktopServicesMenuProps = {
|
||||
isDesktopServicesOpen: boolean;
|
||||
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refreshCurrentInstanceLabel: () => Promise<void>;
|
||||
shortcutLabel: (actionId: string) => string;
|
||||
shortcutLabel: (actionId: ShortcutActionId) => string;
|
||||
remoteUpdateInfo: UpdateInfo | null;
|
||||
remoteUpdateChecking: boolean;
|
||||
remoteUpdateError: string | null;
|
||||
@@ -1445,7 +1446,7 @@ export const Header: React.FC = () => {
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const shortcutLabel = React.useCallback((actionId: string) => {
|
||||
const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||
}, [shortcutOverrides]);
|
||||
|
||||
@@ -1461,51 +1462,27 @@ export const Header: React.FC = () => {
|
||||
}, [isDesktopApp, t]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
} else {
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
}
|
||||
useKeybinds({
|
||||
toggle_services_menu: () => {
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The desktop menu holds one destination now, so this shortcut opens it
|
||||
// rather than cycling. The binding is kept: it is user-configurable and
|
||||
// silently dropping it would break existing setups.
|
||||
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, cycleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
if (servicesTabs.length === 0) return;
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
},
|
||||
// The desktop menu holds one destination now, so this shortcut opens it
|
||||
// rather than cycling. The binding is kept: it is user-configurable and
|
||||
// silently dropping it would break existing setups.
|
||||
cycle_services_tab: () => {
|
||||
if (servicesTabs.length === 0) return false;
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
},
|
||||
toggle_context_plan: () => {
|
||||
handleOpenContextPlan();
|
||||
},
|
||||
});
|
||||
|
||||
const desktopSidebarActions = (
|
||||
<>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { TerminalShellOption } from '@/lib/api/types';
|
||||
import { isTerminalShell } from '@/lib/terminalShell';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
interface Option<T extends string> {
|
||||
id: T;
|
||||
@@ -1480,7 +1481,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
|
||||
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
|
||||
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>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import {
|
||||
isFilesystemError,
|
||||
type FilesystemErrorReason,
|
||||
@@ -360,9 +361,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
);
|
||||
const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
? '⌘'
|
||||
: 'Ctrl';
|
||||
const submitModifierLabel = formatShortcutForDisplay('mod');
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
? t('directoryExplorerDialog.actions.alreadyAdded')
|
||||
: isCloneMode
|
||||
|
||||
@@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
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 { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||
@@ -22,6 +26,7 @@ import { cn } from '@/lib/utils';
|
||||
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
||||
|
||||
type SwitcherVariant = 'default' | 'compact';
|
||||
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
|
||||
|
||||
type SessionSwitcherDropdownProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({
|
||||
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
@@ -69,7 +74,9 @@ type SwitcherContentProps = {
|
||||
};
|
||||
|
||||
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 { t } = useI18n();
|
||||
|
||||
@@ -79,6 +86,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
||||
}, [onSelect, openNewSessionDraft]);
|
||||
|
||||
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) => {
|
||||
setExpandedParents((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 (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
<BaseMenu.Item
|
||||
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
|
||||
onClick={handleNewSession}
|
||||
className={cn(
|
||||
'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();
|
||||
}}
|
||||
data-slot="session-switcher-item"
|
||||
data-switcher-item-id={session.id}
|
||||
className={cn(
|
||||
'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',
|
||||
|
||||
@@ -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 = {
|
||||
scopeProjectId?: string | null;
|
||||
currentSessionId?: string | null;
|
||||
/** How many parent sessions to return (default 7 — the desktop dropdown). */
|
||||
maxParents?: number;
|
||||
};
|
||||
@@ -46,8 +47,69 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
|
||||
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[] => {
|
||||
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 projects = useProjectsStore((state) => state.projects);
|
||||
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));
|
||||
});
|
||||
|
||||
const parents = activeSessions
|
||||
.filter((session) => !session.time?.archived)
|
||||
const parents = selectSwitcherParents(
|
||||
activeSessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
scopeProjectId,
|
||||
currentSessionId,
|
||||
(session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null,
|
||||
maxParents,
|
||||
// btw forks stay hidden until promoted to a full session
|
||||
.filter((session) => !isBtwSession(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);
|
||||
(session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))),
|
||||
);
|
||||
|
||||
const buildNode = (session: Session): SessionNode => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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";
|
||||
|
||||
type AsChildProps = { asChild?: boolean };
|
||||
@@ -34,11 +36,21 @@ function renderFromAsChild(asChild: boolean | undefined, children: React.ReactNo
|
||||
return { children };
|
||||
}
|
||||
|
||||
type DropdownMenuProps = React.ComponentProps<typeof BaseMenu.Root> & {
|
||||
disableGlobalShortcuts?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenu({
|
||||
disableGlobalShortcuts = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.Root>) {
|
||||
}: DropdownMenuProps) {
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | 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>(() => ({
|
||||
portalContainer,
|
||||
collisionBoundary,
|
||||
@@ -46,9 +58,24 @@ function DropdownMenu({
|
||||
setCollisionBoundary,
|
||||
}), [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 (
|
||||
<DropdownPortalContext.Provider value={portalContextValue}>
|
||||
<BaseMenu.Root {...props} />
|
||||
<BaseMenu.Root
|
||||
{...props}
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
</DropdownPortalContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -116,11 +143,23 @@ function DropdownMenuContent({
|
||||
style,
|
||||
children,
|
||||
onCloseAutoFocus,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const portalContext = React.useContext(DropdownPortalContext);
|
||||
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 (
|
||||
<BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}>
|
||||
<BaseMenu.Positioner
|
||||
@@ -143,6 +182,7 @@ function DropdownMenuContent({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Popup>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from 'react';
|
||||
|
||||
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.key.toLowerCase() === 'n') return 'ArrowDown';
|
||||
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
|
||||
|
||||
@@ -8,6 +8,8 @@ import { cn } from "@/lib/utils"
|
||||
import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger"
|
||||
import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { shortcutRegistry } from "@/lib/shortcuts";
|
||||
import { handleDropdownNavigationKey } from "./dropdown-navigation";
|
||||
|
||||
type AsChildProps = { asChild?: boolean };
|
||||
type AsChildRenderProps = {
|
||||
@@ -38,15 +40,22 @@ type SelectRootProps<Value extends string = string> = Omit<
|
||||
value?: Value;
|
||||
defaultValue?: Value;
|
||||
onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void;
|
||||
disableGlobalShortcuts?: boolean;
|
||||
};
|
||||
|
||||
function Select<Value extends string = string>({
|
||||
onValueChange,
|
||||
modal = false,
|
||||
disableGlobalShortcuts = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: SelectRootProps<Value>) {
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | 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>(() => ({
|
||||
portalContainer,
|
||||
collisionBoundary,
|
||||
@@ -63,9 +72,26 @@ function Select<Value extends string = string>({
|
||||
[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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -184,12 +210,24 @@ function SelectContent({
|
||||
align,
|
||||
collisionAvoidance,
|
||||
constrainToMain = false,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) {
|
||||
const portalContext = React.useContext(SelectPortalContext);
|
||||
const alignItemWithTrigger = position === "item-aligned";
|
||||
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 (
|
||||
<BaseSelect.Portal container={portalToBody ? undefined : portalContainer || undefined}>
|
||||
<BaseSelect.Positioner
|
||||
@@ -214,6 +252,7 @@ function SelectContent({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
outerClassName={cn(
|
||||
@@ -253,13 +292,17 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
showSelectedBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.Item>) {
|
||||
}: React.ComponentProps<typeof BaseSelect.Item> & {
|
||||
showSelectedBackground?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BaseSelect.Item
|
||||
data-slot="select-item"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
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 { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
@@ -75,7 +75,8 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
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 { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
|
||||
@@ -1032,7 +1033,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
@@ -1759,35 +1759,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
setAutoSaveStatus('idle');
|
||||
}, [selectedFile?.path]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!hasModifier(e)) {
|
||||
return;
|
||||
}
|
||||
useKeybinds({
|
||||
save_file: (event) => {
|
||||
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
|
||||
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
// Cancel pending auto-save; user wants immediate save
|
||||
if (autoSaveTimerRef.current) {
|
||||
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);
|
||||
// Cancel pending auto-save because the explicit save should run immediately.
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isSaving, saveDraft]);
|
||||
if (!isSaving) {
|
||||
void saveDraft().then((saved) => {
|
||||
if (!saved) return;
|
||||
setAutoSaveStatus('saved');
|
||||
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 loadId = activeFileLoadIdRef.current + 1;
|
||||
@@ -2906,42 +2899,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useKeybind('open_go_to_line', (event) => {
|
||||
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 target = event.target as Element | null;
|
||||
if (target?.closest('[role="dialog"]')) {
|
||||
return;
|
||||
}
|
||||
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
|
||||
const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]'));
|
||||
if (isTypingTarget && !isEditorTarget) return false;
|
||||
|
||||
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
|
||||
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]);
|
||||
setIsGoToLineOpen(true);
|
||||
});
|
||||
|
||||
const editorFontSize = useUIStore((state) => state.editorFontSize);
|
||||
|
||||
@@ -3196,6 +3168,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
|
||||
const docked = layout === 'docked';
|
||||
const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file'));
|
||||
const wrapperCls = docked
|
||||
? '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';
|
||||
@@ -3225,14 +3198,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
<Icon name="check" className="size-3.5" />
|
||||
{t('filesView.editor.saved')}
|
||||
</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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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"
|
||||
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` })}
|
||||
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
|
||||
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut })}
|
||||
aria-label={t('filesView.editor.saveAria', { shortcut: saveShortcut })}
|
||||
>
|
||||
<Icon name="save-3" className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -187,6 +191,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const settingsPageRaw = useUIStore((state) => state.settingsPage);
|
||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
|
||||
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
||||
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
|
||||
@@ -728,7 +733,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: showBackButton
|
||||
? t('settings.view.actions.backToSettings')
|
||||
: 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) => {
|
||||
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
|
||||
@@ -1077,7 +1090,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
@@ -1105,7 +1118,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
<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 { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
type TerminalViewProps = {
|
||||
visible?: boolean;
|
||||
@@ -968,7 +969,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
onClick={() => handleModifierToggle('ctrl')}
|
||||
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>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -981,7 +982,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
onClick={() => handleModifierToggle('alt')}
|
||||
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>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -15,3 +15,10 @@ export function shouldStopDropdownImeEscape(
|
||||
&& event.key === 'Escape'
|
||||
&& (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 { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
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 { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useKeybinds } from './useKeybind';
|
||||
import { isEditableEventTarget } from './keyboard-shortcut-dom';
|
||||
|
||||
export const useMiniChatKeyboardShortcuts = () => {
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
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(() => {
|
||||
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (eventMatchesShortcut(event, combo('focus_input'))) {
|
||||
event.preventDefault();
|
||||
focusChatInput();
|
||||
const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => {
|
||||
if (!dispatcher.hasActivePrefix()) return;
|
||||
// An unmodified completion key typed into an editable target is only a
|
||||
// deliberate sequence when the prefix was armed from that same target;
|
||||
// otherwise it is regular typing and must not be swallowed.
|
||||
if (
|
||||
!event.ctrlKey && !event.metaKey && !event.altKey
|
||||
&& isEditableEventTarget(event.target)
|
||||
&& dispatcher.getActivePrefixTarget() !== event.target
|
||||
) {
|
||||
dispatcher.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) {
|
||||
if (dispatcher.dispatchActivePrefix(event)) {
|
||||
event.preventDefault();
|
||||
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);
|
||||
});
|
||||
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);
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
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);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [openNewSessionDraft, shortcutOverrides]);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, [dispatcher]);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,66 @@ import {
|
||||
} from '@/components/chat/message/selectionMarkdown';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { shortcutRegistry } from '@/lib/shortcuts';
|
||||
|
||||
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 => {
|
||||
if (!node) {
|
||||
return false;
|
||||
|
||||
@@ -108,13 +108,13 @@ describe('shortcut defaults', () => {
|
||||
list.push(action.id);
|
||||
byBinding.set(combo, list);
|
||||
}
|
||||
for (const [combo, ids] of byBinding) {
|
||||
if (ids.length <= 1) continue;
|
||||
const whitelisted = RUNTIME_EXCLUSIVE_BINDING_PAIRS.some(
|
||||
const conflicts = [...byBinding.entries()]
|
||||
.filter(([, ids]) => ids.length > 1)
|
||||
.filter(([, ids]) => !RUNTIME_EXCLUSIVE_BINDING_PAIRS.some(
|
||||
(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', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { isDesktopShell } from "@/lib/desktop";
|
||||
import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch";
|
||||
import type { I18nKey } from "@/lib/i18n";
|
||||
|
||||
@@ -28,24 +27,6 @@ export const getRevealLabelKey = (): I18nKey => {
|
||||
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 = (
|
||||
value: string,
|
||||
options?: { maxLength?: number }
|
||||
|
||||
Reference in New Issue
Block a user