diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f213c11f..b9d90cc4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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) { diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index f3b03529..33066e13 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 886aad6e..5368d208 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -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(null); + const worktreeTriggerRef = React.useRef(null); + const handlePickerKeyDown = (event: React.KeyboardEvent) => { + 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 (
setOpenPicker(open ? 'worktree' : null)} + onValueChange={handleDirectoryChange} + disableGlobalShortcuts > @@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {selectedBranchLabel ?? t('chat.chatInput.branch')} - + {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} - + {projectRootBranchOption.label} @@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{worktreeBranchOptions.map((option) => ( - + {option.pending ? '⏳ ' : ''}{option.label} ))} {selectedDirectory && !selectedBranchIsKnown ? ( - + {selectedBranchLabel} ) : null} diff --git a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx index 74251911..d740de34 100644 --- a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx +++ b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx @@ -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 ( @@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
{t('chat.chatInput.focusMode.label')} - - {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} - + {shortcut ? {shortcut} : null}
diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index bcdca931..29718932 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -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 = ({ containerR const openRafRef = React.useRef(null); const mouseUpTimeoutRef = React.useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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; diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 8af08eaf..c9abd28d 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -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(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' diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index fd69095a..3c56891d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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>; refreshCurrentInstanceLabel: () => Promise; - 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 = ( <> diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index f7d203fc..20f973ed 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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 { id: T; @@ -1480,7 +1481,10 @@ export const OpenChamberVisualSettings: React.FC 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'), + })} /> )} diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index e9e75314..1700286e 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -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 = ( 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 diff --git a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx index 91a874cc..017e6b56 100644 --- a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx +++ b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx @@ -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 ( - + {children} 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>(new Set()); + const contentRef = React.useRef(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('[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 ( -
+
({ + 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)); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts index 8e0f5e77..8bdc5b31 100644 --- a/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts @@ -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, + sessionOrderRanks: Map, + 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(); + 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; }; diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index e8fd327f..aec5df61 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -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 & { + disableGlobalShortcuts?: boolean; +}; + function DropdownMenu({ + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props -}: React.ComponentProps) { +}: DropdownMenuProps) { const [portalContainer, setPortalContainer] = React.useState(null); const [collisionBoundary, setCollisionBoundary] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ 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['onOpenChange']> = (nextOpen, eventDetails) => { + if (open === undefined) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen, eventDetails); + }; + return ( - + ) } @@ -116,11 +143,23 @@ function DropdownMenuContent({ style, children, onCloseAutoFocus, + onKeyDown, ...props }: ContentProps) { const portalContext = React.useContext(DropdownPortalContext); void onCloseAutoFocus + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }; + return ( {children} diff --git a/packages/ui/src/components/ui/dropdown-navigation.ts b/packages/ui/src/components/ui/dropdown-navigation.ts index bba31b73..2bd90bf7 100644 --- a/packages/ui/src/components/ui/dropdown-navigation.ts +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -2,7 +2,7 @@ import type React from 'react'; import { isIMECompositionEvent } from '@/lib/ime'; -export function getDropdownNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { +function getDropdownNavigationKey(event: Pick): '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'; diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index 0fb0ea0e..14a0daa2 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -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 = Omit< value?: Value; defaultValue?: Value; onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void; + disableGlobalShortcuts?: boolean; }; function Select({ onValueChange, modal = false, + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props }: SelectRootProps) { const [portalContainer, setPortalContainer] = React.useState(null); const [collisionBoundary, setCollisionBoundary] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, collisionBoundary, @@ -63,9 +72,26 @@ function Select({ [onValueChange] ); + React.useLayoutEffect(() => { + if (!disableGlobalShortcuts || !isOpen) return; + return shortcutRegistry.suspend(); + }, [disableGlobalShortcuts, isOpen]); + + const handleOpenChange: NonNullable['onOpenChange']> = (nextOpen, eventDetails) => { + if (open === undefined) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen, eventDetails); + }; + return ( - + ) } @@ -184,12 +210,24 @@ function SelectContent({ align, collisionAvoidance, constrainToMain = false, + onKeyDown, ...props }: React.ComponentProps & SelectContentExtra) { const portalContext = React.useContext(SelectPortalContext); const alignItemWithTrigger = position === "item-aligned"; const portalContainer = portalContext?.portalContainer ?? null; + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }; + return ( ) { +}: React.ComponentProps & { + showSelectedBackground?: boolean; +}) { return ( = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ mode = 'full' }) => { {t('filesView.editor.saved')} - ) : 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 }), diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index dbec130d..3be50394 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -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 = ({ 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(initialMobileStage); @@ -728,7 +733,15 @@ export const SettingsView: React.FC = ({ 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 = ({ 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" > @@ -1105,7 +1118,7 @@ export const SettingsView: React.FC = ({ 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" > diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 21d45de0..8e3e83f7 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -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 = ({ visible }) => { onClick={() => handleModifierToggle('ctrl')} disabled={quickKeysDisabled} > - {t('terminalView.quickKeys.controlLabel')} + {formatShortcutForDisplay('ctrl')} {t('terminalView.quickKeys.controlModifierAria')}