From b525532b9d3e8efc4ac388a0af9027f5887c3f8a Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 16:46:38 +0100 Subject: [PATCH 01/23] feat: configurable keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now remap any of the 17 configurable shortcuts via Settings > Shortcuts (or the ⌘/ sheet). Custom bindings fully replace all defaults (including alternates) and multiple custom combos per action are supported. Bindings persist across sessions via electronStorage. - Add `configurable` flag + `DEFAULT_KEYBINDINGS` map to keyboard-shortcuts.ts - New `keybindingsStore` (persist + electronStorage) with array-based custom combos, conflict detection helper, and individual/bulk reset - New `useShortcut(id)` hook — reactive Zustand selector, feeds useHotkeys - New `Keycap` component extracted to avoid circular imports - New `ShortcutRecorder` component: click + to enter recording mode, captures keydown, shows conflict toast, per-binding × remove, per-shortcut ↩ reset - Update all useHotkeys call sites (GlobalEventHandlers, SpaceSwitcher, usePanelKeyboardShortcuts, ExternalAppsOpener) to use useShortcut() - KeyboardShortcutsSheet: configurable rows render ShortcutRecorder instead of static keycaps; "Reset all shortcuts" button shown when customisations exist Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218 --- .../features/command/keyboard-shortcuts.ts | 67 ++++++ .../panels/hooks/usePanelKeyboardShortcuts.ts | 4 +- .../components/ExternalAppsOpener.tsx | 8 +- .../src/primitives/KeyboardShortcutsSheet.tsx | 90 ++++---- packages/ui/src/primitives/Keycap.tsx | 42 ++++ .../ui/src/primitives/ShortcutRecorder.tsx | 193 ++++++++++++++++++ .../ui/src/primitives/hooks/useShortcut.ts | 6 + packages/ui/src/shell/GlobalEventHandlers.tsx | 43 ++-- packages/ui/src/shell/SpaceSwitcher.tsx | 9 +- packages/ui/src/shell/keybindingsStore.ts | 88 ++++++++ 10 files changed, 484 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/primitives/Keycap.tsx create mode 100644 packages/ui/src/primitives/ShortcutRecorder.tsx create mode 100644 packages/ui/src/primitives/hooks/useShortcut.ts create mode 100644 packages/ui/src/shell/keybindingsStore.ts diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index adc2bc2c19..9726e8e93c 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -36,6 +36,7 @@ export interface KeyboardShortcut { category: ShortcutCategory; context?: string; alternateKeys?: string; + configurable?: boolean; } export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ @@ -45,24 +46,28 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "New task", category: "general", alternateKeys: "mod+t", + configurable: true, }, { id: "command-menu", keys: SHORTCUTS.COMMAND_MENU, description: "Open command menu", category: "general", + configurable: true, }, { id: "settings", keys: SHORTCUTS.SETTINGS, description: "Open settings", category: "general", + configurable: true, }, { id: "shortcuts", keys: SHORTCUTS.SHORTCUTS_SHEET, description: "Show keyboard shortcuts", category: "general", + configurable: true, }, { id: "switch-messaging-mode", @@ -76,6 +81,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keys: SHORTCUTS.INBOX, description: "Open inbox", category: "navigation", + configurable: true, }, { id: "switch-task", @@ -89,6 +95,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous task", category: "navigation", alternateKeys: "ctrl+shift+tab", + configurable: true, }, { id: "next-task", @@ -96,42 +103,49 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Next task", category: "navigation", alternateKeys: "ctrl+tab", + configurable: true, }, { id: "space-up", keys: SHORTCUTS.SPACE_UP, description: "Previous space", category: "navigation", + configurable: true, }, { id: "space-down", keys: SHORTCUTS.SPACE_DOWN, description: "Next space", category: "navigation", + configurable: true, }, { id: "go-back", keys: SHORTCUTS.GO_BACK, description: "Go back", category: "navigation", + configurable: true, }, { id: "go-forward", keys: SHORTCUTS.GO_FORWARD, description: "Go forward", category: "navigation", + configurable: true, }, { id: "toggle-left-sidebar", keys: SHORTCUTS.TOGGLE_LEFT_SIDEBAR, description: "Toggle left sidebar", category: "navigation", + configurable: true, }, { id: "toggle-review-panel", keys: SHORTCUTS.TOGGLE_REVIEW_PANEL, description: "Toggle review panel", category: "navigation", + configurable: true, }, { id: "switch-tab", @@ -146,6 +160,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Close active tab", category: "panels", context: "Task detail", + configurable: true, }, { id: "open-in-editor", @@ -153,6 +168,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Open in external editor", category: "panels", context: "Task detail", + configurable: true, }, { id: "copy-path", @@ -160,6 +176,15 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Copy file path", category: "panels", context: "Task detail", + configurable: true, + }, + { + id: "toggle-focus", + keys: SHORTCUTS.TOGGLE_FOCUS, + description: "Toggle focus mode", + category: "panels", + context: "Task detail (worktree)", + configurable: true, }, { id: "find-in-conversation", @@ -226,6 +251,48 @@ export const CATEGORY_LABELS: Record = { editor: "Editor", }; +export const CONFIGURABLE_SHORTCUT_IDS = [ + "command-menu", + "new-task", + "settings", + "shortcuts", + "inbox", + "prev-task", + "next-task", + "space-up", + "space-down", + "go-back", + "go-forward", + "toggle-left-sidebar", + "toggle-review-panel", + "close-tab", + "open-in-editor", + "copy-path", + "toggle-focus", +] as const; + +export type ConfigurableShortcutId = (typeof CONFIGURABLE_SHORTCUT_IDS)[number]; + +export const DEFAULT_KEYBINDINGS: Record = { + "command-menu": SHORTCUTS.COMMAND_MENU, + "new-task": SHORTCUTS.NEW_TASK, + settings: SHORTCUTS.SETTINGS, + shortcuts: SHORTCUTS.SHORTCUTS_SHEET, + inbox: SHORTCUTS.INBOX, + "prev-task": SHORTCUTS.PREV_TASK, + "next-task": SHORTCUTS.NEXT_TASK, + "space-up": SHORTCUTS.SPACE_UP, + "space-down": SHORTCUTS.SPACE_DOWN, + "go-back": SHORTCUTS.GO_BACK, + "go-forward": SHORTCUTS.GO_FORWARD, + "toggle-left-sidebar": SHORTCUTS.TOGGLE_LEFT_SIDEBAR, + "toggle-review-panel": SHORTCUTS.TOGGLE_REVIEW_PANEL, + "close-tab": SHORTCUTS.CLOSE_TAB, + "open-in-editor": SHORTCUTS.OPEN_IN_EDITOR, + "copy-path": SHORTCUTS.COPY_PATH, + "toggle-focus": SHORTCUTS.TOGGLE_FOCUS, +}; + export function getShortcutsByCategory(): Record< ShortcutCategory, KeyboardShortcut[] diff --git a/packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts b/packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts index 2e4d79cf99..01a8af0d9f 100644 --- a/packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts +++ b/packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts @@ -1,3 +1,4 @@ +import { useShortcut } from "../../../primitives/hooks/useShortcut"; import { useHotkeys } from "react-hotkeys-hook"; import { SHORTCUTS } from "../../command/keyboard-shortcuts"; import { usePanelLayoutStore } from "../panelLayoutStore"; @@ -5,6 +6,7 @@ import { getLeafPanel } from "../panelStoreHelpers"; export function usePanelKeyboardShortcuts(taskId: string): void { const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); + const closeTabKey = useShortcut("close-tab"); useHotkeys( SHORTCUTS.SWITCH_TAB, @@ -42,7 +44,7 @@ export function usePanelKeyboardShortcuts(taskId: string): void { ); useHotkeys( - SHORTCUTS.CLOSE_TAB, + closeTabKey, (event) => { event.preventDefault(); diff --git a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx index 2d4727280b..b7a96f65f4 100644 --- a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx +++ b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx @@ -1,3 +1,4 @@ +import { useShortcut } from "../../../primitives/hooks/useShortcut"; import { CodeIcon, CopyIcon } from "@phosphor-icons/react"; import { Button, @@ -58,8 +59,11 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { await openExternalApp({ type: "copy-path" }, targetPath, displayName); }, [openExternalApp, targetPath]); + const openInEditorKey = useShortcut("open-in-editor"); + const copyPathKey = useShortcut("copy-path"); + useHotkeys( - SHORTCUTS.OPEN_IN_EDITOR, + openInEditorKey, (event) => { event.preventDefault(); handleOpenDefault(); @@ -69,7 +73,7 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { ); useHotkeys( - SHORTCUTS.COPY_PATH, + copyPathKey, (event) => { event.preventDefault(); handleCopyPath(); diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index 3fc1521e84..841829acdf 100644 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -1,48 +1,16 @@ -import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; -import { useMemo, useState } from "react"; +import { Keycap } from "./Keycap"; +import { ShortcutRecorder } from "./ShortcutRecorder"; +import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { CATEGORY_LABELS, + type ConfigurableShortcutId, formatHotkeyParts, getShortcutsByCategory, type ShortcutCategory, } from "../features/command/keyboard-shortcuts"; - -function Keycap({ label, size = "md" }: { label: string; size?: "sm" | "md" }) { - const [pressed, setPressed] = useState(false); - const isSmall = size === "sm"; - const minW = isSmall ? "22px" : "28px"; - const h = isSmall ? "22px" : "28px"; - const fontSize = isSmall ? "11px" : "13px"; - const shadowSize = isSmall ? "2px" : "3px"; - - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation - setPressed(true)} - onMouseUp={() => setPressed(false)} - onMouseLeave={() => setPressed(false)} - style={{ - minWidth: minW, - height: h, - fontSize, - fontFamily: "system-ui, -apple-system, sans-serif", - lineHeight: 1, - borderBottomWidth: pressed ? "1px" : shadowSize, - borderBottomColor: "var(--gray-7)", - transform: pressed - ? `translateY(${isSmall ? "1px" : "2px"})` - : "translateY(0)", - transition: - "transform 80ms ease-out, border-bottom-width 80ms ease-out", - }} - className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)" - > - {label} - - ); -} +import { useKeybindingsStore } from "../shell/keybindingsStore"; interface KeyboardShortcutsSheetProps { open: boolean; @@ -110,6 +78,13 @@ function ShortcutsHeader() { export function KeyboardShortcutsList() { const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); + const hasCustomBindings = useKeybindingsStore((s) => + Object.keys(s.customKeybindings).some( + (k) => + (s.customKeybindings[k as ConfigurableShortcutId]?.length ?? 0) > 0, + ), + ); + const resetAll = useKeybindingsStore((s) => s.resetAll); const categoryOrder: ShortcutCategory[] = [ "general", @@ -149,19 +124,46 @@ export function KeyboardShortcutsList() { align="center" justify="between" px="3" - className="border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" + className="group border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" > - {shortcut.description} - + + {shortcut.description} + {shortcut.context && ( + + {shortcut.context} + + )} + + {shortcut.configurable ? ( + + ) : ( + + )} ))} ); })} + + {hasCustomBindings && ( + + + + )} ); } diff --git a/packages/ui/src/primitives/Keycap.tsx b/packages/ui/src/primitives/Keycap.tsx new file mode 100644 index 0000000000..e94f419197 --- /dev/null +++ b/packages/ui/src/primitives/Keycap.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; + +interface KeycapProps { + label: string; + size?: "sm" | "md"; +} + +export function Keycap({ label, size = "md" }: KeycapProps) { + const [pressed, setPressed] = useState(false); + const isSmall = size === "sm"; + const minW = isSmall ? "22px" : "28px"; + const h = isSmall ? "22px" : "28px"; + const fontSize = isSmall ? "11px" : "13px"; + const shadowSize = isSmall ? "2px" : "3px"; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation + setPressed(true)} + onMouseUp={() => setPressed(false)} + onMouseLeave={() => setPressed(false)} + style={{ + minWidth: minW, + height: h, + fontSize, + fontFamily: "system-ui, -apple-system, sans-serif", + lineHeight: 1, + borderBottomWidth: pressed ? "1px" : shadowSize, + borderBottomColor: "var(--gray-7)", + transform: pressed + ? `translateY(${isSmall ? "1px" : "2px"})` + : "translateY(0)", + transition: + "transform 80ms ease-out, border-bottom-width 80ms ease-out", + }} + className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)" + > + {label} + + ); +} diff --git a/packages/ui/src/primitives/ShortcutRecorder.tsx b/packages/ui/src/primitives/ShortcutRecorder.tsx new file mode 100644 index 0000000000..cd3698d045 --- /dev/null +++ b/packages/ui/src/primitives/ShortcutRecorder.tsx @@ -0,0 +1,193 @@ +import { Keycap } from "./Keycap"; +import { ArrowCounterClockwise, Plus, X } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { + type ConfigurableShortcutId, + formatHotkeyParts, + KEYBOARD_SHORTCUTS, +} from "../features/command/keyboard-shortcuts"; +import { findConflict, useKeybindingsStore } from "../shell/keybindingsStore"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +function captureCombo(e: KeyboardEvent): string | null { + const bare = ["Meta", "Control", "Shift", "Alt"]; + if (bare.includes(e.key)) return null; + + const parts: string[] = []; + if (e.metaKey || e.ctrlKey) parts.push("mod"); + if (e.shiftKey) parts.push("shift"); + if (e.altKey) parts.push("alt"); + + const key = e.key.toLowerCase(); + parts.push(key); + return parts.join("+"); +} + +interface RecordingInputProps { + onCapture: (combo: string) => void; + onCancel: () => void; +} + +function RecordingInput({ onCapture, onCancel }: RecordingInputProps) { + const ref = useRef(null); + + useEffect(() => { + ref.current?.focus(); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.key === "Escape") { + onCancel(); + return; + } + const combo = captureCombo(e.nativeEvent); + if (combo) onCapture(combo); + }, + [onCapture, onCancel], + ); + + return ( + {}} + tabIndex={0} + onKeyDown={handleKeyDown} + onBlur={onCancel} + className="h-[28px] min-w-[120px] cursor-text rounded-[6px] border border-(--accent-8) bg-(--accent-2) px-2 text-center text-(--accent-11) text-[12px] outline-none ring-(--accent-8) ring-1 placeholder:text-(--accent-11)" + /> + ); +} + +interface ShortcutRecorderProps { + id: ConfigurableShortcutId; +} + +export function ShortcutRecorder({ id }: ShortcutRecorderProps) { + const [recording, setRecording] = useState(false); + const customs = useKeybindingsStore((s) => s.customKeybindings[id] ?? []); + const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); + const removeKeybinding = useKeybindingsStore((s) => s.removeKeybinding); + const resetShortcut = useKeybindingsStore((s) => s.resetShortcut); + const hasCustom = customs.length > 0; + + const shortcutEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === id); + + const handleCapture = useCallback( + (combo: string) => { + const conflict = findConflict(combo, id); + if (conflict) { + const conflictEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === conflict); + toast.error( + `Already used by "${conflictEntry?.description ?? conflict}"`, + ); + setRecording(false); + return; + } + addKeybinding(id, combo); + setRecording(false); + }, + [id, addKeybinding], + ); + + if (!shortcutEntry) return null; + + return ( + + {recording ? ( + setRecording(false)} + /> + ) : hasCustom ? ( + customs.map((key, i) => ( + + {i > 0 && ( + + or + + )} + + {formatHotkeyParts(key).map((part) => ( + + ))} + + + + )) + ) : ( + + )} + + {!recording && ( + + )} + + {hasCustom && !recording && ( + + )} + + ); +} + +function DefaultKeys({ + shortcutEntry, +}: { + shortcutEntry: (typeof KEYBOARD_SHORTCUTS)[number]; +}) { + const primaryParts = formatHotkeyParts(shortcutEntry.keys); + const alternateParts = shortcutEntry.alternateKeys + ? formatHotkeyParts(shortcutEntry.alternateKeys) + : null; + + return ( + + + {primaryParts.map((part) => ( + + ))} + + {alternateParts && ( + <> + + or + + + {alternateParts.map((part) => ( + + ))} + + + )} + + ); +} diff --git a/packages/ui/src/primitives/hooks/useShortcut.ts b/packages/ui/src/primitives/hooks/useShortcut.ts new file mode 100644 index 0000000000..5a1894af82 --- /dev/null +++ b/packages/ui/src/primitives/hooks/useShortcut.ts @@ -0,0 +1,6 @@ +import type { ConfigurableShortcutId } from "../../features/command/keyboard-shortcuts"; +import { resolveKey, useKeybindingsStore } from "../../shell/keybindingsStore"; + +export function useShortcut(id: ConfigurableShortcutId): string { + return useKeybindingsStore((s) => resolveKey(s.customKeybindings, id)); +} diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index 7c1552752c..ee441b3ccd 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -28,6 +28,7 @@ import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { logger } from "@posthog/ui/shell/logger"; import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; +import { useShortcut } from "../primitives/hooks/useShortcut"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -153,33 +154,43 @@ export function GlobalEventHandlers({ preventDefault: true, } as const; - useHotkeys(SHORTCUTS.COMMAND_MENU, onToggleCommandMenu, { + const commandMenuKey = useShortcut("command-menu"); + const newTaskKey = useShortcut("new-task"); + const settingsKey = useShortcut("settings"); + const goBackKey = useShortcut("go-back"); + const goForwardKey = useShortcut("go-forward"); + const toggleLeftSidebarKey = useShortcut("toggle-left-sidebar"); + const toggleReviewPanelKey = useShortcut("toggle-review-panel"); + const shortcutsSheetKey = useShortcut("shortcuts"); + const inboxKey = useShortcut("inbox"); + const prevTaskKey = useShortcut("prev-task"); + const nextTaskKey = useShortcut("next-task"); + const toggleFocusKey = useShortcut("toggle-focus"); + + useHotkeys(commandMenuKey, onToggleCommandMenu, { ...globalOptions, enabled: !commandMenuOpen, }); - useHotkeys(SHORTCUTS.NEW_TASK, handleFocusTaskMode, globalOptions); - useHotkeys(SHORTCUTS.SETTINGS, handleOpenSettings, globalOptions); - useHotkeys(SHORTCUTS.GO_BACK, goBack, globalOptions); - useHotkeys(SHORTCUTS.GO_FORWARD, goForward, globalOptions); + useHotkeys(newTaskKey, handleFocusTaskMode, globalOptions); + useHotkeys(settingsKey, handleOpenSettings, globalOptions); + useHotkeys(goBackKey, goBack, globalOptions); + useHotkeys(goForwardKey, goForward, globalOptions); + const handleToggleReview = useCallback(() => { if (!currentTaskId) return; const mode = getReviewMode(currentTaskId); setReviewMode(currentTaskId, mode === "closed" ? "split" : "closed"); }, [currentTaskId, getReviewMode, setReviewMode]); - useHotkeys(SHORTCUTS.TOGGLE_LEFT_SIDEBAR, toggleLeftSidebar, globalOptions); - useHotkeys(SHORTCUTS.TOGGLE_REVIEW_PANEL, handleToggleReview, globalOptions); - useHotkeys(SHORTCUTS.SHORTCUTS_SHEET, onToggleShortcutsSheet, globalOptions); - useHotkeys(SHORTCUTS.INBOX, navigateToInbox, globalOptions); - useHotkeys(SHORTCUTS.PREV_TASK, handlePrevTask, globalOptions, [ - handlePrevTask, - ]); - useHotkeys(SHORTCUTS.NEXT_TASK, handleNextTask, globalOptions, [ - handleNextTask, - ]); + useHotkeys(toggleLeftSidebarKey, toggleLeftSidebar, globalOptions); + useHotkeys(toggleReviewPanelKey, handleToggleReview, globalOptions); + useHotkeys(shortcutsSheetKey, onToggleShortcutsSheet, globalOptions); + useHotkeys(inboxKey, navigateToInbox, globalOptions); + useHotkeys(prevTaskKey, handlePrevTask, globalOptions, [handlePrevTask]); + useHotkeys(nextTaskKey, handleNextTask, globalOptions, [handleNextTask]); useHotkeys( - SHORTCUTS.TOGGLE_FOCUS, + toggleFocusKey, handleToggleFocus, { ...globalOptions, diff --git a/packages/ui/src/shell/SpaceSwitcher.tsx b/packages/ui/src/shell/SpaceSwitcher.tsx index a5823fd026..1e69b41ad5 100644 --- a/packages/ui/src/shell/SpaceSwitcher.tsx +++ b/packages/ui/src/shell/SpaceSwitcher.tsx @@ -1,6 +1,6 @@ import type { TaskData } from "@posthog/core/sidebar/sidebarData.types"; import type { Task } from "@posthog/shared/domain-types"; -import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useShortcut } from "../primitives/hooks/useShortcut"; import { useCallback, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -88,8 +88,11 @@ export function SpaceSwitcher({ navigateToSlot(next); }, [tasks.length, totalSlots, currentSlot, navigateToSlot]); + const spaceUpKey = useShortcut("space-up"); + const spaceDownKey = useShortcut("space-down"); + useHotkeys( - SHORTCUTS.SPACE_UP, + spaceUpKey, (e) => { if (isInputWithContent()) return; e.preventDefault(); @@ -99,7 +102,7 @@ export function SpaceSwitcher({ [navigatePrev], ); useHotkeys( - SHORTCUTS.SPACE_DOWN, + spaceDownKey, (e) => { if (isInputWithContent()) return; e.preventDefault(); diff --git a/packages/ui/src/shell/keybindingsStore.ts b/packages/ui/src/shell/keybindingsStore.ts new file mode 100644 index 0000000000..72ec0b05ff --- /dev/null +++ b/packages/ui/src/shell/keybindingsStore.ts @@ -0,0 +1,88 @@ +import { + CONFIGURABLE_SHORTCUT_IDS, + type ConfigurableShortcutId, + DEFAULT_KEYBINDINGS, +} from "../features/command/keyboard-shortcuts"; +import { electronStorage } from "./rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface KeybindingsState { + customKeybindings: Partial>; + getKey: (id: ConfigurableShortcutId) => string; + addKeybinding: (id: ConfigurableShortcutId, key: string) => void; + removeKeybinding: (id: ConfigurableShortcutId, key: string) => void; + resetShortcut: (id: ConfigurableShortcutId) => void; + resetAll: () => void; +} + +export function resolveKey( + customKeybindings: Partial>, + id: ConfigurableShortcutId, +): string { + const customs = customKeybindings[id]; + if (customs && customs.length > 0) return customs.join(","); + return DEFAULT_KEYBINDINGS[id]; +} + +export function findConflict( + newKey: string, + excludeId: ConfigurableShortcutId, +): ConfigurableShortcutId | null { + const state = useKeybindingsStore.getState(); + for (const id of CONFIGURABLE_SHORTCUT_IDS) { + if (id === excludeId) continue; + const keyStr = state.getKey(id); + const parts = keyStr.split(",").map((k) => k.trim()); + if (parts.includes(newKey)) return id; + } + return null; +} + +export const useKeybindingsStore = create()( + persist( + (set, get) => ({ + customKeybindings: {}, + + getKey: (id) => resolveKey(get().customKeybindings, id), + + addKeybinding: (id, key) => { + const existing = get().customKeybindings[id] ?? []; + if (existing.includes(key)) return; + set({ + customKeybindings: { + ...get().customKeybindings, + [id]: [...existing, key], + }, + }); + }, + + removeKeybinding: (id, key) => { + const existing = get().customKeybindings[id] ?? []; + const updated = existing.filter((k) => k !== key); + set({ + customKeybindings: { + ...get().customKeybindings, + [id]: updated, + }, + }); + }, + + resetShortcut: (id) => { + const { [id]: _removed, ...rest } = get().customKeybindings; + set({ + customKeybindings: rest as Partial< + Record + >, + }); + }, + + resetAll: () => set({ customKeybindings: {} }), + }), + { + name: "keybindings-storage", + storage: electronStorage, + partialize: (state) => ({ customKeybindings: state.customKeybindings }), + }, + ), +); From 84b411005072a058140c50f310431fd32cb2dca3 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 19:14:51 +0100 Subject: [PATCH 02/23] fix: require modifier key when recording custom shortcut Bare letter keys (e.g. just "k") would fire every time that character is typed anywhere in the app. Require at least mod/ctrl/alt to be held. Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218 --- packages/ui/src/primitives/ShortcutRecorder.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/primitives/ShortcutRecorder.tsx b/packages/ui/src/primitives/ShortcutRecorder.tsx index cd3698d045..0e67b5062b 100644 --- a/packages/ui/src/primitives/ShortcutRecorder.tsx +++ b/packages/ui/src/primitives/ShortcutRecorder.tsx @@ -13,6 +13,7 @@ import { toast } from "sonner"; function captureCombo(e: KeyboardEvent): string | null { const bare = ["Meta", "Control", "Shift", "Alt"]; if (bare.includes(e.key)) return null; + if (!(e.metaKey || e.ctrlKey || e.altKey)) return null; const parts: string[] = []; if (e.metaKey || e.ctrlKey) parts.push("mod"); From b26bdc27613d87b68fcdf78a4f99974d2eb5c3a6 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 19:24:44 +0100 Subject: [PATCH 03/23] test: unit tests for keybindingsStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 tests covering resolveKey, addKeybinding, removeKeybinding, resetShortcut, resetAll, getKey, and findConflict — including conflict detection against comma-separated default alternates. Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218 --- .../ui/src/shell/keybindingsStore.test.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 packages/ui/src/shell/keybindingsStore.test.ts diff --git a/packages/ui/src/shell/keybindingsStore.test.ts b/packages/ui/src/shell/keybindingsStore.test.ts new file mode 100644 index 0000000000..85c61aea3f --- /dev/null +++ b/packages/ui/src/shell/keybindingsStore.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./rendererStorage", () => ({ + electronStorage: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { DEFAULT_KEYBINDINGS } from "../features/command/keyboard-shortcuts"; +import { + findConflict, + resolveKey, + useKeybindingsStore, +} from "./keybindingsStore"; + +describe("keybindingsStore", () => { + beforeEach(() => { + useKeybindingsStore.setState({ customKeybindings: {} }); + }); + + describe("resolveKey", () => { + it("returns default when no custom binding exists", () => { + expect(resolveKey({}, "command-menu")).toBe( + DEFAULT_KEYBINDINGS["command-menu"], + ); + }); + + it("returns joined custom bindings when present", () => { + expect( + resolveKey({ "command-menu": ["ctrl+p", "ctrl+q"] }, "command-menu"), + ).toBe("ctrl+p,ctrl+q"); + }); + + it("falls back to default when custom array is empty", () => { + expect(resolveKey({ "command-menu": [] }, "command-menu")).toBe( + DEFAULT_KEYBINDINGS["command-menu"], + ); + }); + }); + + describe("addKeybinding", () => { + it("adds a custom binding", () => { + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+p"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual(["ctrl+p"]); + }); + + it("appends a second binding", () => { + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+p"); + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+q"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual(["ctrl+p", "ctrl+q"]); + }); + + it("deduplicates identical bindings", () => { + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+p"); + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+p"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual(["ctrl+p"]); + }); + + it("custom bindings replace defaults in getKey", () => { + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+p"); + expect(useKeybindingsStore.getState().getKey("command-menu")).toBe( + "ctrl+p", + ); + }); + }); + + describe("removeKeybinding", () => { + beforeEach(() => { + useKeybindingsStore.setState({ + customKeybindings: { "command-menu": ["ctrl+p", "ctrl+q"] }, + }); + }); + + it("removes the specified binding", () => { + useKeybindingsStore.getState().removeKeybinding("command-menu", "ctrl+p"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual(["ctrl+q"]); + }); + + it("leaves an empty array when the last binding is removed", () => { + useKeybindingsStore.getState().removeKeybinding("command-menu", "ctrl+p"); + useKeybindingsStore.getState().removeKeybinding("command-menu", "ctrl+q"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual([]); + }); + + it("resolveKey falls back to default when custom array is emptied", () => { + useKeybindingsStore.getState().removeKeybinding("command-menu", "ctrl+p"); + useKeybindingsStore.getState().removeKeybinding("command-menu", "ctrl+q"); + expect( + resolveKey( + useKeybindingsStore.getState().customKeybindings, + "command-menu", + ), + ).toBe(DEFAULT_KEYBINDINGS["command-menu"]); + }); + }); + + describe("resetShortcut", () => { + beforeEach(() => { + useKeybindingsStore.setState({ + customKeybindings: { + "command-menu": ["ctrl+p"], + settings: ["ctrl+alt+s"], + }, + }); + }); + + it("removes the entry for the given shortcut", () => { + useKeybindingsStore.getState().resetShortcut("command-menu"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toBeUndefined(); + }); + + it("does not affect other shortcuts", () => { + useKeybindingsStore.getState().resetShortcut("command-menu"); + expect(useKeybindingsStore.getState().customKeybindings.settings).toEqual( + ["ctrl+alt+s"], + ); + }); + + it("getKey returns default after reset", () => { + useKeybindingsStore.getState().resetShortcut("command-menu"); + expect(useKeybindingsStore.getState().getKey("command-menu")).toBe( + DEFAULT_KEYBINDINGS["command-menu"], + ); + }); + }); + + describe("resetAll", () => { + it("clears all custom bindings", () => { + useKeybindingsStore.setState({ + customKeybindings: { + "command-menu": ["ctrl+p"], + settings: ["ctrl+alt+s"], + inbox: ["ctrl+shift+i"], + }, + }); + useKeybindingsStore.getState().resetAll(); + expect(useKeybindingsStore.getState().customKeybindings).toEqual({}); + }); + + it("all shortcuts return defaults after resetAll", () => { + useKeybindingsStore.setState({ + customKeybindings: { "command-menu": ["ctrl+p"] }, + }); + useKeybindingsStore.getState().resetAll(); + expect(useKeybindingsStore.getState().getKey("command-menu")).toBe( + DEFAULT_KEYBINDINGS["command-menu"], + ); + }); + }); + + describe("getKey", () => { + it("returns the default binding when nothing is customised", () => { + expect(useKeybindingsStore.getState().getKey("command-menu")).toBe( + DEFAULT_KEYBINDINGS["command-menu"], + ); + }); + + it("returns a single custom binding", () => { + useKeybindingsStore.setState({ + customKeybindings: { "command-menu": ["ctrl+p"] }, + }); + expect(useKeybindingsStore.getState().getKey("command-menu")).toBe( + "ctrl+p", + ); + }); + + it("joins multiple custom bindings with comma", () => { + useKeybindingsStore.setState({ + customKeybindings: { "command-menu": ["ctrl+p", "ctrl+q"] }, + }); + expect(useKeybindingsStore.getState().getKey("command-menu")).toBe( + "ctrl+p,ctrl+q", + ); + }); + }); + + describe("findConflict", () => { + beforeEach(() => { + useKeybindingsStore.setState({ customKeybindings: {} }); + }); + + it("returns null when no conflict exists", () => { + expect(findConflict("ctrl+z", "command-menu")).toBeNull(); + }); + + it("detects a conflict with a default binding on another shortcut", () => { + // mod+b is the default for toggle-left-sidebar + expect(findConflict("mod+b", "command-menu")).toBe("toggle-left-sidebar"); + }); + + it("does not flag the excluded shortcut's own default as a conflict", () => { + // mod+k is command-menu's own default — should not conflict with itself + expect(findConflict("mod+k", "command-menu")).toBeNull(); + }); + + it("detects a conflict within comma-separated default alternates", () => { + // prev-task default includes "ctrl+shift+tab" as an alternate + expect(findConflict("ctrl+shift+tab", "command-menu")).toBe("prev-task"); + }); + + it("detects a conflict with a custom binding on another shortcut", () => { + useKeybindingsStore.setState({ + customKeybindings: { settings: ["ctrl+alt+s"] }, + }); + expect(findConflict("ctrl+alt+s", "command-menu")).toBe("settings"); + }); + + it("does not conflict with custom binding on the excluded shortcut itself", () => { + useKeybindingsStore.setState({ + customKeybindings: { "command-menu": ["ctrl+p"] }, + }); + // ctrl+p is a custom binding on command-menu — assigning it to command-menu again is fine + expect(findConflict("ctrl+p", "command-menu")).toBeNull(); + }); + }); +}); From f0511bba39d4f5b880d07f476b7b1224a18d72af Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 21:24:30 +0100 Subject: [PATCH 04/23] fix: sync shortcut label displays with live keybindings store - KeyboardShortcutsSheet header now reads the "shortcuts" key via useShortcut() so the trigger keycap updates when remapped - ExternalAppsOpener dropdown labels for open-in-editor and copy-path now derive from useShortcut() + formatHotkeyParts() instead of hardcoded Mac-only symbols test(e2e): add Playwright shortcut sheet tests Covers sheet open/close, category sections, hover controls, recording mode entry/cancellation, bare-key rejection, saving bindings, conflict detection, removing bindings, per-shortcut reset, and reset-all. Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218 --- apps/code/tests/e2e/tests/shortcuts.spec.ts | 311 ++++++++++++++++++ .../components/ExternalAppsOpener.tsx | 10 +- .../src/primitives/KeyboardShortcutsSheet.tsx | 4 +- 3 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 apps/code/tests/e2e/tests/shortcuts.spec.ts diff --git a/apps/code/tests/e2e/tests/shortcuts.spec.ts b/apps/code/tests/e2e/tests/shortcuts.spec.ts new file mode 100644 index 0000000000..bed4c9a896 --- /dev/null +++ b/apps/code/tests/e2e/tests/shortcuts.spec.ts @@ -0,0 +1,311 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../fixtures/electron"; + +const isMac = process.platform === "darwin"; +const modKey = isMac ? "Meta" : "Control"; + +// Opens the shortcuts sheet via keyboard shortcut. +async function openShortcutsSheet(window: Page) { + await window.keyboard.press(`${modKey}+Slash`); + await window.getByText("Keyboard Combos").waitFor({ timeout: 5000 }); +} + +// Returns true when the main layout is rendered (requires authenticated state). +async function isMainLayout(window: Page): Promise { + await window.locator("#root > *").waitFor({ timeout: 30000 }); + await window + .locator("text=Loading") + .waitFor({ state: "hidden", timeout: 15000 }) + .catch(() => {}); + return window + .locator("text=New task") + .first() + .isVisible() + .catch(() => false); +} + +// Clears all custom bindings via the Reset all button if it's visible. +async function resetAllIfNeeded(window: Page) { + try { + await openShortcutsSheet(window); + const resetBtn = window.getByText("Reset all shortcuts to defaults"); + const visible = await resetBtn.isVisible().catch(() => false); + if (visible) await resetBtn.click(); + await window.keyboard.press("Escape"); + } catch {} +} + +test.describe("Configurable Keyboard Shortcuts", () => { + test.beforeEach(async ({ window }) => { + const ready = await isMainLayout(window); + if (!ready) test.skip(); + await resetAllIfNeeded(window); + }); + + // ─── Sheet ──────────────────────────────────────────────────────────────── + + test("shortcuts sheet opens via keyboard shortcut", async ({ window }) => { + await openShortcutsSheet(window); + + await expect(window.getByText("Keyboard Combos")).toBeVisible(); + await expect( + window.getByText("Your cheat codes for shipping faster"), + ).toBeVisible(); + }); + + test("shortcuts sheet shows all category sections", async ({ window }) => { + await openShortcutsSheet(window); + + for (const label of ["General", "Navigation", "Panels & Tabs", "Editor"]) { + await expect(window.getByText(label).first()).toBeVisible(); + } + }); + + // ─── Hover controls ─────────────────────────────────────────────────────── + + test("hovering a configurable row reveals the add (+) button", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await window.getByText("Open command menu").hover(); + await expect(window.getByTitle("Add binding").first()).toBeVisible(); + }); + + test("non-configurable rows do not show an add (+) button", async ({ + window, + }) => { + await openShortcutsSheet(window); + + // "Switch to task 1-9" is intentionally non-configurable + await window.getByText("Switch to task 1-9").hover(); + const addBtns = window.getByTitle("Add binding"); + expect(await addBtns.count()).toBe(0); + }); + + // ─── Recording ──────────────────────────────────────────────────────────── + + test("clicking + enters recording mode", async ({ window }) => { + await openShortcutsSheet(window); + + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + + await expect( + window.locator('input[aria-label="Press new shortcut"]'), + ).toBeVisible(); + }); + + test("pressing Escape cancels recording without closing the sheet", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + + const input = window.locator('input[aria-label="Press new shortcut"]'); + await expect(input).toBeVisible(); + + await window.keyboard.press("Escape"); + + // Input should close… + await expect(input).not.toBeVisible(); + // …but the sheet should stay open + await expect(window.getByText("Keyboard Combos")).toBeVisible(); + }); + + test("bare letter key is rejected in recording mode", async ({ window }) => { + await openShortcutsSheet(window); + + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + + const input = window.locator('input[aria-label="Press new shortcut"]'); + await expect(input).toBeVisible(); + + // Press a bare letter with no modifier — should be ignored + await window.keyboard.press("k"); + + // Input should still be in recording mode (not closed) + await expect(input).toBeVisible(); + }); + + // ─── Saving a binding ───────────────────────────────────────────────────── + + test("recording a valid combo saves it and shows keycap + remove button", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + + // Use ControlOrMeta+Shift+Z — not in the default shortcut set + await window.keyboard.press("ControlOrMeta+Shift+Z"); + + // Recording input should close + await expect( + window.locator('input[aria-label="Press new shortcut"]'), + ).not.toBeVisible({ timeout: 3000 }); + + // Remove and reset buttons should now be visible on hover + await window.getByText("Open inbox").hover(); + await expect(window.getByTitle("Remove binding").first()).toBeVisible(); + await expect(window.getByTitle("Reset to default").first()).toBeVisible(); + }); + + test("can add a second binding to the same shortcut", async ({ window }) => { + await openShortcutsSheet(window); + + // Add first binding + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+Z"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + // Add second binding + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+X"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + // Both remove buttons should be visible (one per binding) + await window.getByText("Open inbox").hover(); + const removeBtns = window.getByTitle("Remove binding"); + expect(await removeBtns.count()).toBe(2); + }); + + // ─── Conflict detection ─────────────────────────────────────────────────── + + test("assigning an already-used combo shows a conflict toast", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await window.getByText("Open command menu").hover(); + await window.getByTitle("Add binding").click(); + + // mod+b is the default for "Toggle left sidebar" + await window.keyboard.press(`${modKey}+b`); + + await expect( + window.getByText('Already used by "Toggle left sidebar"'), + ).toBeVisible({ timeout: 5000 }); + + // Recording should be cancelled — no remove button + await window.getByText("Open command menu").hover(); + await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + }); + + // ─── Removing a binding ─────────────────────────────────────────────────── + + test("clicking × removes a custom binding", async ({ window }) => { + await openShortcutsSheet(window); + + // Add a binding + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+Z"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + // Remove it + await window.getByText("Open inbox").hover(); + await window.getByTitle("Remove binding").click(); + + // Remove and reset buttons should now be gone + await window.getByText("Open inbox").hover(); + await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + await expect(window.getByTitle("Reset to default")).not.toBeVisible(); + }); + + // ─── Per-shortcut reset ─────────────────────────────────────────────────── + + test("↩ resets an individual shortcut to its default", async ({ window }) => { + await openShortcutsSheet(window); + + // Add a binding + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+Z"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + // Reset this shortcut + await window.getByText("Open inbox").hover(); + await window.getByTitle("Reset to default").click(); + + // Should revert — no custom controls visible + await window.getByText("Open inbox").hover(); + await expect(window.getByTitle("Reset to default")).not.toBeVisible(); + await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + }); + + // ─── Reset all ──────────────────────────────────────────────────────────── + + test("Reset all button is hidden when no custom bindings exist", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await expect( + window.getByText("Reset all shortcuts to defaults"), + ).not.toBeVisible(); + }); + + test("Reset all button appears after adding a custom binding", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+Z"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + // Scroll to bottom to find the button + const resetAllBtn = window.getByText("Reset all shortcuts to defaults"); + await resetAllBtn.scrollIntoViewIfNeeded(); + await expect(resetAllBtn).toBeVisible(); + }); + + test("clicking Reset all clears all custom bindings", async ({ window }) => { + await openShortcutsSheet(window); + + // Add bindings to two different shortcuts + await window.getByText("Open inbox").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+Z"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + await window.getByText("Open command menu").hover(); + await window.getByTitle("Add binding").click(); + await window.keyboard.press("ControlOrMeta+Shift+X"); + await window + .locator('input[aria-label="Press new shortcut"]') + .waitFor({ state: "hidden" }); + + // Click Reset all + const resetAllBtn = window.getByText("Reset all shortcuts to defaults"); + await resetAllBtn.scrollIntoViewIfNeeded(); + await resetAllBtn.click(); + + // Button should disappear + await expect(resetAllBtn).not.toBeVisible(); + + // Neither row should have custom controls any more + await window.getByText("Open inbox").hover(); + await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + }); +}); diff --git a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx index b7a96f65f4..e87b321df7 100644 --- a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx +++ b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx @@ -13,7 +13,7 @@ import { import { ChevronDown } from "lucide-react"; import { useCallback } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { SHORTCUTS } from "../../command/keyboard-shortcuts"; +import { formatHotkeyParts } from "../../command/keyboard-shortcuts"; import { useExternalAppAction } from "../../external-apps/useExternalAppAction"; import { useExternalApps } from "../../external-apps/useExternalApps"; @@ -139,7 +139,9 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { )} {app.name} {app.id === defaultApp?.id && ( - ⌘O + + {formatHotkeyParts(openInEditorKey).join("")} + )} ))} @@ -147,7 +149,9 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { Copy Path - ⌘⇧C + + {formatHotkeyParts(copyPathKey).join("")} + diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index 841829acdf..bb8b132a79 100644 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -1,5 +1,6 @@ import { Keycap } from "./Keycap"; import { ShortcutRecorder } from "./ShortcutRecorder"; +import { useShortcut } from "./hooks/useShortcut"; import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -55,7 +56,8 @@ export function KeyboardShortcutsSheet({ } function ShortcutsHeader() { - const triggerParts = formatHotkeyParts("mod+/"); + const shortcutsKey = useShortcut("shortcuts"); + const triggerParts = formatHotkeyParts(shortcutsKey); return ( From c0233d007bac655e6885475f5d591da297735e84 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 22:54:53 +0100 Subject: [PATCH 05/23] fix: show Ctrl on Windows and accept Ctrl-triggered shortcuts Hardcoded Cmd glyphs were leaking onto Windows in the send-messages dropdown and the tiptap paste hint, and two handlers were gated on metaKey only so the corresponding shortcut never fired on Windows (mod+1..9 task switching, Cmd/Ctrl-click multi-select in the inbox). Generated-By: PostHog Code Task-Id: 80405bf7-239f-4b60-a1cf-5a4777fb7218 --- .../src/features/message-editor/tiptap/useTiptapEditor.ts | 3 ++- .../ui/src/features/settings/sections/GeneralSettings.tsx | 5 ++++- packages/ui/src/shell/GlobalEventHandlers.tsx | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 05f713b994..953d6e6e27 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,3 +1,4 @@ +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; import { contentToXml, type FileAttachment, @@ -478,7 +479,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (clipboardText && clipboardText.length > 200) { showPasteHint( "Pasted as text", - "Use ⌘⇧V to paste as a file attachment instead.", + `Use ${formatHotkey("mod+shift+v")} to paste as a file attachment instead.`, ); } diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 9a21c2232b..9902e2da15 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -21,6 +21,7 @@ import { track } from "@posthog/ui/shell/analytics"; import type { ThemePreference } from "@posthog/ui/shell/themeStore"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; import { Button, Flex, Link, Select, Switch, Text } from "@radix-ui/themes"; +import { formatHotkey } from "../../command/keyboard-shortcuts"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; @@ -331,7 +332,9 @@ export function GeneralSettings() { Enter - ⌘ Enter + + {formatHotkey("mod+enter")} + diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index ee441b3ccd..5188a5ddc3 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -28,6 +28,7 @@ import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { logger } from "@posthog/ui/shell/logger"; import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; +import { isMac } from "@posthog/ui/utils/platform"; import { useShortcut } from "../primitives/hooks/useShortcut"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -199,11 +200,13 @@ export function GlobalEventHandlers({ [handleToggleFocus], ); - // Task switching with mod+1-9 + // Task switching with mod+1-9. On macOS, Ctrl+1..9 is reserved for + // SWITCH_TAB (panel tabs), so ignore plain-Ctrl there; on Windows/Linux, + // Ctrl IS mod, so the same event must trigger task switching. useHotkeys( SHORTCUTS.SWITCH_TASK, (event, handler) => { - if (event.ctrlKey && !event.metaKey) return; + if (isMac && event.ctrlKey && !event.metaKey) return; const keyPressed = handler.keys?.[0]; if (!keyPressed) return; From b23738a81da6112858a15cd32bb4653ca1b1608e Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Sat, 23 May 2026 01:12:36 +0100 Subject: [PATCH 06/23] feat: make prompt-history shortcuts configurable; update E2E tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add prompt-history-prev/next to CONFIGURABLE_SHORTCUT_IDS and DEFAULT_KEYBINDINGS so they appear in the shortcuts sheet and can be rebound like any other shortcut - Add tiptapEventToCombo() — accepts shift-only combos (no Ctrl/Meta required) so shift+up/down can be matched against live bindings - Fix eventToCombo() to normalise Arrow-prefixed key names (ArrowUp to up) - Wire useTiptapEditor to resolve prompt-history keys from the store instead of hardcoding event.shiftKey - Fix paste hint toast to show the live paste-as-file binding instead of the hardcoded mod+shift+v string - Fix noStaticElementInteractions lint on recording modal backdrop - Rewrite E2E shortcut tests to match the current recording modal UI (chips + right-click context menu) rather than the old hover-button and inline-input design --- apps/code/tests/e2e/tests/shortcuts.spec.ts | 407 +++++++++++----- .../features/command/keyboard-shortcuts.ts | 55 +++ .../message-editor/tiptap/useTiptapEditor.ts | 51 +- .../task-detail/components/TaskDetail.tsx | 4 +- .../src/primitives/KeyboardShortcutsSheet.tsx | 111 +++-- .../ui/src/primitives/ShortcutRecorder.tsx | 457 ++++++++++++------ .../ui/src/shell/keybindingsStore.test.ts | 86 +++- packages/ui/src/shell/keybindingsStore.ts | 72 ++- 8 files changed, 900 insertions(+), 343 deletions(-) diff --git a/apps/code/tests/e2e/tests/shortcuts.spec.ts b/apps/code/tests/e2e/tests/shortcuts.spec.ts index bed4c9a896..f9dc76f730 100644 --- a/apps/code/tests/e2e/tests/shortcuts.spec.ts +++ b/apps/code/tests/e2e/tests/shortcuts.spec.ts @@ -4,13 +4,11 @@ import { expect, test } from "../fixtures/electron"; const isMac = process.platform === "darwin"; const modKey = isMac ? "Meta" : "Control"; -// Opens the shortcuts sheet via keyboard shortcut. async function openShortcutsSheet(window: Page) { await window.keyboard.press(`${modKey}+Slash`); await window.getByText("Keyboard Combos").waitFor({ timeout: 5000 }); } -// Returns true when the main layout is rendered (requires authenticated state). async function isMainLayout(window: Page): Promise { await window.locator("#root > *").waitFor({ timeout: 30000 }); await window @@ -24,7 +22,6 @@ async function isMainLayout(window: Page): Promise { .catch(() => false); } -// Clears all custom bindings via the Reset all button if it's visible. async function resetAllIfNeeded(window: Page) { try { await openShortcutsSheet(window); @@ -35,6 +32,32 @@ async function resetAllIfNeeded(window: Page) { } catch {} } +// Returns the chip button(s) for a named shortcut. +// Each individual binding renders as a separate button with this title pattern. +function getChips(window: Page, commandLabel: string) { + return window.locator( + `button[title='Click to edit binding for "${commandLabel}"']`, + ); +} + +// Opens the recording modal via right-click → "Add another binding". +async function openAddRecording(window: Page, commandLabel: string) { + await getChips(window, commandLabel).first().click({ button: "right" }); + await window.getByRole("menuitem", { name: "Add another binding" }).click(); + await window + .getByText(`Add new binding for "${commandLabel}"`) + .waitFor({ timeout: 3000 }); +} + +// Records a combo and confirms with Enter. Assumes the recording modal is already open. +async function recordAndConfirm(window: Page, combo: string) { + await window.keyboard.press(combo); + await window + .getByText("Press Enter to confirm, Escape to cancel") + .waitFor({ timeout: 2000 }); + await window.keyboard.press("Enter"); +} + test.describe("Configurable Keyboard Shortcuts", () => { test.beforeEach(async ({ window }) => { const ready = await isMainLayout(window); @@ -61,39 +84,40 @@ test.describe("Configurable Keyboard Shortcuts", () => { } }); - // ─── Hover controls ─────────────────────────────────────────────────────── + // ─── Configurable vs non-configurable ───────────────────────────────────── - test("hovering a configurable row reveals the add (+) button", async ({ + test("configurable rows expose clickable chip buttons", async ({ window, }) => { await openShortcutsSheet(window); - await window.getByText("Open command menu").hover(); - await expect(window.getByTitle("Add binding").first()).toBeVisible(); + // "Open command menu" is configurable + await expect(getChips(window, "Open command menu").first()).toBeVisible(); }); - test("non-configurable rows do not show an add (+) button", async ({ - window, - }) => { + test("non-configurable rows show a tooltip on hover", async ({ window }) => { await openShortcutsSheet(window); // "Switch to task 1-9" is intentionally non-configurable + // The keycap wrapper has a Tooltip with this text; hover to reveal it await window.getByText("Switch to task 1-9").hover(); - const addBtns = window.getByTitle("Add binding"); - expect(await addBtns.count()).toBe(0); + await expect( + window.getByText("This shortcut cannot be customized"), + ).toBeVisible({ timeout: 2000 }); }); - // ─── Recording ──────────────────────────────────────────────────────────── + // ─── Recording modal ────────────────────────────────────────────────────── - test("clicking + enters recording mode", async ({ window }) => { + test("clicking a chip opens the recording modal in edit mode", async ({ + window, + }) => { await openShortcutsSheet(window); - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - - await expect( - window.locator('input[aria-label="Press new shortcut"]'), - ).toBeVisible(); + await getChips(window, "Open inbox").first().click(); + await expect(window.getByText('Edit binding for "Open inbox"')).toBeVisible( + { timeout: 3000 }, + ); + await expect(window.getByText("Press a key combination...")).toBeVisible(); }); test("pressing Escape cancels recording without closing the sheet", async ({ @@ -101,151 +125,292 @@ test.describe("Configurable Keyboard Shortcuts", () => { }) => { await openShortcutsSheet(window); - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - - const input = window.locator('input[aria-label="Press new shortcut"]'); - await expect(input).toBeVisible(); + await getChips(window, "Open inbox").first().click(); + await window + .getByText('Edit binding for "Open inbox"') + .waitFor({ timeout: 3000 }); await window.keyboard.press("Escape"); - // Input should close… - await expect(input).not.toBeVisible(); - // …but the sheet should stay open + // Modal closes, sheet stays open + await expect( + window.getByText('Edit binding for "Open inbox"'), + ).not.toBeVisible(); await expect(window.getByText("Keyboard Combos")).toBeVisible(); }); - test("bare letter key is rejected in recording mode", async ({ window }) => { + test("clicking the backdrop closes recording without saving", async ({ + window, + }) => { await openShortcutsSheet(window); - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); + await getChips(window, "Open inbox").first().click(); + await window + .getByText('Edit binding for "Open inbox"') + .waitFor({ timeout: 3000 }); + + // Click the blurred backdrop — the outer fixed overlay has a backdrop-filter style + await window + .locator('[style*="backdrop-filter"]') + .click({ position: { x: 10, y: 10 } }); + + await expect( + window.getByText('Edit binding for "Open inbox"'), + ).not.toBeVisible({ timeout: 2000 }); + await expect(window.getByText("Keyboard Combos")).toBeVisible(); + }); + + test("bare letter key is ignored in recording mode", async ({ window }) => { + await openShortcutsSheet(window); - const input = window.locator('input[aria-label="Press new shortcut"]'); - await expect(input).toBeVisible(); + await getChips(window, "Open inbox").first().click(); + await window + .getByText('Edit binding for "Open inbox"') + .waitFor({ timeout: 3000 }); - // Press a bare letter with no modifier — should be ignored await window.keyboard.press("k"); - // Input should still be in recording mode (not closed) - await expect(input).toBeVisible(); + // No combo captured — placeholder still shown, modal still open + await expect(window.getByText("Press a key combination...")).toBeVisible(); + await expect( + window.getByText('Edit binding for "Open inbox"'), + ).toBeVisible(); }); - // ─── Saving a binding ───────────────────────────────────────────────────── - - test("recording a valid combo saves it and shows keycap + remove button", async ({ + test("Enter without a captured combo does not close the modal", async ({ window, }) => { await openShortcutsSheet(window); - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); + await getChips(window, "Open inbox").first().click(); + await window + .getByText('Edit binding for "Open inbox"') + .waitFor({ timeout: 3000 }); + + await window.keyboard.press("Enter"); + + // Modal should still be open + await expect( + window.getByText('Edit binding for "Open inbox"'), + ).toBeVisible(); + }); + + // ─── Saving a binding ───────────────────────────────────────────────────── + + test("recording and pressing Enter saves the binding", async ({ window }) => { + await openShortcutsSheet(window); + + await openAddRecording(window, "Open inbox"); - // Use ControlOrMeta+Shift+Z — not in the default shortcut set await window.keyboard.press("ControlOrMeta+Shift+Z"); + await window + .getByText("Press Enter to confirm, Escape to cancel") + .waitFor({ timeout: 2000 }); + await window.keyboard.press("Enter"); - // Recording input should close + // Modal closes await expect( - window.locator('input[aria-label="Press new shortcut"]'), + window.getByText('Add new binding for "Open inbox"'), ).not.toBeVisible({ timeout: 3000 }); - // Remove and reset buttons should now be visible on hover - await window.getByText("Open inbox").hover(); - await expect(window.getByTitle("Remove binding").first()).toBeVisible(); - await expect(window.getByTitle("Reset to default").first()).toBeVisible(); + // The chip for the shortcut should still be visible (now showing the new binding) + await expect(getChips(window, "Open inbox").first()).toBeVisible(); }); - test("can add a second binding to the same shortcut", async ({ window }) => { + test("right-click context menu offers Edit and Add another binding", async ({ + window, + }) => { await openShortcutsSheet(window); - // Add first binding - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - await window.keyboard.press("ControlOrMeta+Shift+Z"); - await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + await getChips(window, "Open inbox").first().click({ button: "right" }); - // Add second binding - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - await window.keyboard.press("ControlOrMeta+Shift+X"); + await expect( + window.getByRole("menuitem", { name: "Edit binding" }), + ).toBeVisible({ timeout: 2000 }); + await expect( + window.getByRole("menuitem", { name: "Add another binding" }), + ).toBeVisible(); + }); + + test("can add a second binding via Add another binding", async ({ + window, + }) => { + await openShortcutsSheet(window); + + // Add first custom binding + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); + + // Add second custom binding + await getChips(window, "Open inbox").first().click({ button: "right" }); + await window.getByRole("menuitem", { name: "Add another binding" }).click(); await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + .getByText('Add new binding for "Open inbox"') + .waitFor({ timeout: 3000 }); + await recordAndConfirm(window, "ControlOrMeta+Shift+X"); + + // Two chips should now exist for this shortcut + await expect(getChips(window, "Open inbox")).toHaveCount(2, { + timeout: 3000, + }); + }); + + test("Add another binding option is absent at the 2-binding limit", async ({ + window, + }) => { + await openShortcutsSheet(window); + + // Fill both custom binding slots + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); - // Both remove buttons should be visible (one per binding) - await window.getByText("Open inbox").hover(); - const removeBtns = window.getByTitle("Remove binding"); - expect(await removeBtns.count()).toBe(2); + await getChips(window, "Open inbox").first().click({ button: "right" }); + await window.getByRole("menuitem", { name: "Add another binding" }).click(); + await recordAndConfirm(window, "ControlOrMeta+Shift+X"); + + // Right-click again — "Add another binding" should be gone + await getChips(window, "Open inbox").first().click({ button: "right" }); + await expect( + window.getByRole("menuitem", { name: "Add another binding" }), + ).not.toBeVisible({ timeout: 1000 }); }); // ─── Conflict detection ─────────────────────────────────────────────────── - test("assigning an already-used combo shows a conflict toast", async ({ + test("pressing an already-used combo shows amber conflict message", async ({ window, }) => { await openShortcutsSheet(window); - await window.getByText("Open command menu").hover(); - await window.getByTitle("Add binding").click(); + await openAddRecording(window, "Open command menu"); // mod+b is the default for "Toggle left sidebar" await window.keyboard.press(`${modKey}+b`); await expect( - window.getByText('Already used by "Toggle left sidebar"'), - ).toBeVisible({ timeout: 5000 }); - - // Recording should be cancelled — no remove button - await window.getByText("Open command menu").hover(); - await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + window.getByText(/Conflicts with "Toggle left sidebar"/), + ).toBeVisible({ timeout: 3000 }); }); - // ─── Removing a binding ─────────────────────────────────────────────────── + test("Enter is blocked while a conflict is shown", async ({ window }) => { + await openShortcutsSheet(window); + + await openAddRecording(window, "Open command menu"); + + await window.keyboard.press(`${modKey}+b`); + await window + .getByText(/Conflicts with "Toggle left sidebar"/) + .waitFor({ timeout: 2000 }); + + // Enter should NOT dismiss the modal while conflict is active + await window.keyboard.press("Enter"); + await expect( + window.getByText(/Conflicts with "Toggle left sidebar"/), + ).toBeVisible(); + }); - test("clicking × removes a custom binding", async ({ window }) => { + test("resolving a conflict allows the binding to be saved", async ({ + window, + }) => { await openShortcutsSheet(window); - // Add a binding - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); + await openAddRecording(window, "Open command menu"); + + // First press a conflicting key, then a safe one + await window.keyboard.press(`${modKey}+b`); + await window.getByText(/Conflicts with/).waitFor({ timeout: 2000 }); + await window.keyboard.press("ControlOrMeta+Shift+Z"); await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + .getByText("Press Enter to confirm, Escape to cancel") + .waitFor({ timeout: 2000 }); + await window.keyboard.press("Enter"); - // Remove it - await window.getByText("Open inbox").hover(); - await window.getByTitle("Remove binding").click(); + await expect( + window.getByText('Add new binding for "Open command menu"'), + ).not.toBeVisible({ timeout: 3000 }); + }); - // Remove and reset buttons should now be gone - await window.getByText("Open inbox").hover(); - await expect(window.getByTitle("Remove binding")).not.toBeVisible(); - await expect(window.getByTitle("Reset to default")).not.toBeVisible(); + // ─── Removing a binding ─────────────────────────────────────────────────── + + test("right-click Remove binding removes a custom binding", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); + + // Add a second so we can remove one without hitting the single-binding guard + await getChips(window, "Open inbox").first().click({ button: "right" }); + await window.getByRole("menuitem", { name: "Add another binding" }).click(); + await recordAndConfirm(window, "ControlOrMeta+Shift+X"); + + await expect(getChips(window, "Open inbox")).toHaveCount(2, { + timeout: 3000, + }); + + await getChips(window, "Open inbox").first().click({ button: "right" }); + await window.getByRole("menuitem", { name: "Remove binding" }).click(); + + await expect(getChips(window, "Open inbox")).toHaveCount(1, { + timeout: 3000, + }); + }); + + test("Remove binding is disabled and shows a tooltip when it is the only binding", async ({ + window, + }) => { + await openShortcutsSheet(window); + + // "Open inbox" has one default binding — Remove should be disabled + await getChips(window, "Open inbox").first().click({ button: "right" }); + + const removeItem = window.getByRole("menuitem", { name: "Remove binding" }); + // Radix disables items via aria-disabled or data-disabled + const isDisabled = + (await removeItem.getAttribute("aria-disabled")) === "true" || + (await removeItem.getAttribute("data-disabled")) !== null; + expect(isDisabled).toBe(true); }); // ─── Per-shortcut reset ─────────────────────────────────────────────────── - test("↩ resets an individual shortcut to its default", async ({ window }) => { + test("Reset to default is disabled when already at default", async ({ + window, + }) => { await openShortcutsSheet(window); - // Add a binding - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - await window.keyboard.press("ControlOrMeta+Shift+Z"); - await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + await getChips(window, "Open inbox").first().click({ button: "right" }); + + const resetItem = window.getByRole("menuitem", { + name: "Reset to default", + }); + const isDisabled = + (await resetItem.getAttribute("aria-disabled")) === "true" || + (await resetItem.getAttribute("data-disabled")) !== null; + expect(isDisabled).toBe(true); + }); - // Reset this shortcut - await window.getByText("Open inbox").hover(); - await window.getByTitle("Reset to default").click(); + test("Reset to default reverts a customised shortcut", async ({ window }) => { + await openShortcutsSheet(window); - // Should revert — no custom controls visible - await window.getByText("Open inbox").hover(); - await expect(window.getByTitle("Reset to default")).not.toBeVisible(); - await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); + + // Reset + await getChips(window, "Open inbox").first().click({ button: "right" }); + await window.getByRole("menuitem", { name: "Reset to default" }).click(); + + // Now Reset to default should be disabled again (back at default) + await getChips(window, "Open inbox").first().click({ button: "right" }); + const resetItem = window.getByRole("menuitem", { + name: "Reset to default", + }); + const isDisabled = + (await resetItem.getAttribute("aria-disabled")) === "true" || + (await resetItem.getAttribute("data-disabled")) !== null; + expect(isDisabled).toBe(true); }); // ─── Reset all ──────────────────────────────────────────────────────────── @@ -265,14 +430,9 @@ test.describe("Configurable Keyboard Shortcuts", () => { }) => { await openShortcutsSheet(window); - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - await window.keyboard.press("ControlOrMeta+Shift+Z"); - await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); - // Scroll to bottom to find the button const resetAllBtn = window.getByText("Reset all shortcuts to defaults"); await resetAllBtn.scrollIntoViewIfNeeded(); await expect(resetAllBtn).toBeVisible(); @@ -281,31 +441,16 @@ test.describe("Configurable Keyboard Shortcuts", () => { test("clicking Reset all clears all custom bindings", async ({ window }) => { await openShortcutsSheet(window); - // Add bindings to two different shortcuts - await window.getByText("Open inbox").hover(); - await window.getByTitle("Add binding").click(); - await window.keyboard.press("ControlOrMeta+Shift+Z"); - await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); - await window.getByText("Open command menu").hover(); - await window.getByTitle("Add binding").click(); - await window.keyboard.press("ControlOrMeta+Shift+X"); - await window - .locator('input[aria-label="Press new shortcut"]') - .waitFor({ state: "hidden" }); + await openAddRecording(window, "Open command menu"); + await recordAndConfirm(window, "ControlOrMeta+Shift+X"); - // Click Reset all const resetAllBtn = window.getByText("Reset all shortcuts to defaults"); await resetAllBtn.scrollIntoViewIfNeeded(); await resetAllBtn.click(); - // Button should disappear - await expect(resetAllBtn).not.toBeVisible(); - - // Neither row should have custom controls any more - await window.getByText("Open inbox").hover(); - await expect(window.getByTitle("Remove binding")).not.toBeVisible(); + await expect(resetAllBtn).not.toBeVisible({ timeout: 3000 }); }); }); diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 9726e8e93c..68c994d35a 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -22,6 +22,7 @@ export const SHORTCUTS = { SPACE_UP: "mod+up", SPACE_DOWN: "mod+down", FIND_IN_CONVERSATION: "mod+f", + FILE_PICKER: "mod+p", BLUR: "escape", SUBMIT_BLUR: "mod+enter", SWITCH_MESSAGING_MODE: "mod+s", @@ -193,12 +194,21 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ category: "panels", context: "Task detail", }, + { + id: "file-picker", + keys: SHORTCUTS.FILE_PICKER, + description: "Open file picker", + category: "panels", + context: "Task detail", + configurable: true, + }, { id: "paste-as-file", keys: SHORTCUTS.PASTE_AS_FILE, description: "Paste as file attachment", category: "editor", context: "Message editor", + configurable: true, }, { id: "prompt-history-prev", @@ -206,6 +216,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous prompt (when input is empty)", category: "editor", context: "Message editor", + configurable: true, }, { id: "prompt-history-next", @@ -213,6 +224,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Next prompt (when input is empty)", category: "editor", context: "Message editor", + configurable: true, }, { id: "editor-bold", @@ -269,6 +281,10 @@ export const CONFIGURABLE_SHORTCUT_IDS = [ "open-in-editor", "copy-path", "toggle-focus", + "file-picker", + "paste-as-file", + "prompt-history-prev", + "prompt-history-next", ] as const; export type ConfigurableShortcutId = (typeof CONFIGURABLE_SHORTCUT_IDS)[number]; @@ -291,6 +307,10 @@ export const DEFAULT_KEYBINDINGS: Record = { "open-in-editor": SHORTCUTS.OPEN_IN_EDITOR, "copy-path": SHORTCUTS.COPY_PATH, "toggle-focus": SHORTCUTS.TOGGLE_FOCUS, + "file-picker": SHORTCUTS.FILE_PICKER, + "paste-as-file": SHORTCUTS.PASTE_AS_FILE, + "prompt-history-prev": "shift+up", + "prompt-history-next": "shift+down", }; export function getShortcutsByCategory(): Record< @@ -309,6 +329,41 @@ export function getShortcutsByCategory(): Record< return grouped; } +/** + * Convert a DOM KeyboardEvent to the normalised combo string used by the + * keybindings store (e.g. "mod+shift+v"). Returns null for bare modifier presses. + */ +export function eventToCombo(e: KeyboardEvent): string | null { + const bare = ["Meta", "Control", "Shift", "Alt"]; + if (bare.includes(e.key)) return null; + if (!(e.metaKey || e.ctrlKey || e.altKey)) return null; + + const parts: string[] = []; + if (e.metaKey || e.ctrlKey) parts.push("mod"); + if (e.shiftKey) parts.push("shift"); + if (e.altKey) parts.push("alt"); + // Normalize "ArrowUp" → "up", "ArrowDown" → "down", etc. to match stored bindings. + parts.push(e.key.toLowerCase().replace(/^arrow/, "")); + return parts.join("+"); +} + +/** + * Like eventToCombo but also accepts shift-only combos (no ctrl/meta/alt required). + * Used inside Tiptap's handleKeyDown to match bindings such as "shift+up". + */ +export function tiptapEventToCombo(e: KeyboardEvent): string | null { + const bare = ["Meta", "Control", "Shift", "Alt"]; + if (bare.includes(e.key)) return null; + if (!(e.metaKey || e.ctrlKey || e.altKey || e.shiftKey)) return null; + + const parts: string[] = []; + if (e.metaKey || e.ctrlKey) parts.push("mod"); + if (e.shiftKey) parts.push("shift"); + if (e.altKey) parts.push("alt"); + parts.push(e.key.toLowerCase().replace(/^arrow/, "")); + return parts.join("+"); +} + function formatKey(key: string): string { const k = key.trim().toLowerCase(); if (k === "mod") return isMac ? "⌘" : "Ctrl"; diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 953d6e6e27..1429e9c2e5 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,4 +1,12 @@ -import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { + eventToCombo, + formatHotkey, + tiptapEventToCombo, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { + splitBindings, + useKeybindingsStore, +} from "@posthog/ui/shell/keybindingsStore"; import { contentToXml, type FileAttachment, @@ -259,11 +267,12 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { }, }, handleKeyDown: (view, event) => { - if ( - event.key === "v" && - (event.metaKey || event.ctrlKey) && - event.shiftKey - ) { + const eventCombo = eventToCombo(event); + const pasteAsFileKey = useKeybindingsStore + .getState() + .getKey("paste-as-file"); + const pasteAsFileBindings = splitBindings(pasteAsFileKey); + if (eventCombo && pasteAsFileBindings.includes(eventCombo)) { event.preventDefault(); (async () => { try { @@ -305,9 +314,30 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const currentText = view.state.doc.textContent; const isEmpty = !currentText.trim(); +<<<<<<< HEAD:packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts const history = historyGetter?.() ?? []; if (event.key === "ArrowUp" && isEmpty) { +======= + const keybindings = useKeybindingsStore.getState(); + const tiptapCombo = tiptapEventToCombo(event); + const forcePrev = + tiptapCombo !== null && + splitBindings(keybindings.getKey("prompt-history-prev")).includes( + tiptapCombo, + ); + const forceNext = + tiptapCombo !== null && + splitBindings(keybindings.getKey("prompt-history-next")).includes( + tiptapCombo, + ); + const history = historyGetter?.() ?? []; + + if ( + event.key === "ArrowUp" && + (forcePrev || isEmpty || isAtStart) + ) { +>>>>>>> 13027004 (feat: make prompt-history shortcuts configurable; update E2E tests):apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts if (taskId) { const queuedContent = sessionStoreSetters.dequeueMessagesAsText(taskId); @@ -334,7 +364,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } } +<<<<<<< HEAD:packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts if (event.key === "ArrowDown" && isEmpty) { +======= + if ( + event.key === "ArrowDown" && + (forceNext || isEmpty || isAtEnd) + ) { +>>>>>>> 13027004 (feat: make prompt-history shortcuts configurable; update E2E tests):apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts const newText = historyActions.navigateDown(history); if (newText !== null) { event.preventDefault(); @@ -479,7 +516,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (clipboardText && clipboardText.length > 200) { showPasteHint( "Pasted as text", - `Use ${formatHotkey("mod+shift+v")} to paste as a file attachment instead.`, + `Use ${formatHotkey(useKeybindingsStore.getState().getKey("paste-as-file"))} to paste as a file attachment instead.`, ); } diff --git a/packages/ui/src/features/task-detail/components/TaskDetail.tsx b/packages/ui/src/features/task-detail/components/TaskDetail.tsx index 1486fcceb9..94ace3585b 100644 --- a/packages/ui/src/features/task-detail/components/TaskDetail.tsx +++ b/packages/ui/src/features/task-detail/components/TaskDetail.tsx @@ -1,4 +1,5 @@ import type { Task } from "@posthog/shared/domain-types"; +import { useShortcut } from "../../../primitives/hooks/useShortcut"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys, useHotkeysContext } from "react-hotkeys-hook"; @@ -74,6 +75,7 @@ export function TaskDetail({ : effectiveRepoPath; const [filePickerOpen, setFilePickerOpen] = useState(false); + const filePickerKey = useShortcut("file-picker"); const { enableScope, disableScope } = useHotkeysContext(); @@ -84,7 +86,7 @@ export function TaskDetail({ }; }, [enableScope, disableScope]); - useHotkeys("mod+p", () => setFilePickerOpen(true), { + useHotkeys(filePickerKey, () => setFilePickerOpen(true), { enableOnContentEditable: true, enableOnFormTags: true, preventDefault: true, diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index bb8b132a79..969a109c58 100644 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -1,5 +1,6 @@ import { Keycap } from "./Keycap"; import { ShortcutRecorder } from "./ShortcutRecorder"; +import { Tooltip } from "./Tooltip"; import { useShortcut } from "./hooks/useShortcut"; import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -34,9 +35,10 @@ export function KeyboardShortcutsSheet({ e.preventDefault()} - className="max-h-[80vh] overflow-hidden" + className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden" > - + {/* Header */} + + + ); +} + function ShortcutsHeader() { const shortcutsKey = useShortcut("shortcuts"); const triggerParts = formatHotkeyParts(shortcutsKey); @@ -80,13 +118,6 @@ function ShortcutsHeader() { export function KeyboardShortcutsList() { const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); - const hasCustomBindings = useKeybindingsStore((s) => - Object.keys(s.customKeybindings).some( - (k) => - (s.customKeybindings[k as ConfigurableShortcutId]?.length ?? 0) > 0, - ), - ); - const resetAll = useKeybindingsStore((s) => s.resetAll); const categoryOrder: ShortcutCategory[] = [ "general", @@ -125,10 +156,11 @@ export function KeyboardShortcutsList() { key={shortcut.id} align="center" justify="between" + gap="3" px="3" className="group border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" > - + {shortcut.description} {shortcut.context && ( @@ -136,43 +168,30 @@ export function KeyboardShortcutsList() { )} - {shortcut.configurable ? ( - - ) : ( - - )} +
+ {shortcut.configurable ? ( + + ) : ( + + )} +
))}
); })} - - {hasCustomBindings && ( - - - - )} ); } function SingleShortcutKeys({ keys }: { keys: string }) { const parts = formatHotkeyParts(keys); - return ( {parts.map((part) => ( @@ -189,11 +208,7 @@ function ShortcutKeys({ keys: string; alternateKeys?: string; }) { - if (!alternateKeys) { - return ; - } - - return ( + const inner = alternateKeys ? ( @@ -201,5 +216,19 @@ function ShortcutKeys({ + ) : ( + + ); + + return ( + +
+ {inner} +
+
); } diff --git a/packages/ui/src/primitives/ShortcutRecorder.tsx b/packages/ui/src/primitives/ShortcutRecorder.tsx index 0e67b5062b..27ffd0fed2 100644 --- a/packages/ui/src/primitives/ShortcutRecorder.tsx +++ b/packages/ui/src/primitives/ShortcutRecorder.tsx @@ -1,194 +1,355 @@ import { Keycap } from "./Keycap"; -import { ArrowCounterClockwise, Plus, X } from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; +import { Tooltip } from "./Tooltip"; +import { ContextMenu, Flex, Text } from "@radix-ui/themes"; import { type ConfigurableShortcutId, + DEFAULT_KEYBINDINGS, + eventToCombo, formatHotkeyParts, KEYBOARD_SHORTCUTS, } from "../features/command/keyboard-shortcuts"; -import { findConflict, useKeybindingsStore } from "../shell/keybindingsStore"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; - -function captureCombo(e: KeyboardEvent): string | null { - const bare = ["Meta", "Control", "Shift", "Alt"]; - if (bare.includes(e.key)) return null; - if (!(e.metaKey || e.ctrlKey || e.altKey)) return null; - - const parts: string[] = []; - if (e.metaKey || e.ctrlKey) parts.push("mod"); - if (e.shiftKey) parts.push("shift"); - if (e.altKey) parts.push("alt"); - - const key = e.key.toLowerCase(); - parts.push(key); - return parts.join("+"); +import { + findConflict, + MAX_CUSTOM_BINDINGS, + splitBindings, + useKeybindingsStore, +} from "../shell/keybindingsStore"; +import { useCallback, useEffect, useState } from "react"; + +// --------------------------------------------------------------------------- +// Key capture +// --------------------------------------------------------------------------- + +function formatComboLabel(combo: string): string { + return formatHotkeyParts(combo).join("+"); } -interface RecordingInputProps { - onCapture: (combo: string) => void; - onCancel: () => void; +// --------------------------------------------------------------------------- +// Recording modal +// --------------------------------------------------------------------------- + +interface RecordingModalProps { + commandLabel: string; + /** null = adding a new binding, string = the binding key being edited */ + editingKey: string | null; + shortcutId: ConfigurableShortcutId; + onClose: () => void; } -function RecordingInput({ onCapture, onCancel }: RecordingInputProps) { - const ref = useRef(null); +export function ShortcutRecordingModal({ + commandLabel, + editingKey, + shortcutId, + onClose, +}: RecordingModalProps) { + const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); + const updateKeybinding = useKeybindingsStore((s) => s.updateKeybinding); + const [captured, setCaptured] = useState(null); + const [conflict, setConflict] = useState(null); + // Capture at the window level — no focus required on the input element. useEffect(() => { - ref.current?.focus(); - }, []); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { + const handler = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); + if (e.key === "Escape") { - onCancel(); + onClose(); return; } - const combo = captureCombo(e.nativeEvent); - if (combo) onCapture(combo); - }, - [onCapture, onCancel], + + if (e.key === "Enter") { + if (captured && !conflict) { + if (editingKey) { + updateKeybinding(shortcutId, editingKey, captured); + } else { + addKeybinding(shortcutId, captured); + } + onClose(); + } + return; + } + + const combo = eventToCombo(e); + if (!combo) return; + + const result = findConflict(combo, shortcutId); + if (result.description) { + setConflict(result.description); + setCaptured(combo); + } else { + setConflict(null); + setCaptured(combo); + } + }; + + window.addEventListener("keydown", handler, { capture: true }); + return () => + window.removeEventListener("keydown", handler, { capture: true }); + }, [ + captured, + conflict, + editingKey, + shortcutId, + addKeybinding, + updateKeybinding, + onClose, + ]); + + const isAdding = editingKey === null; + const title = isAdding + ? `Add new binding for "${commandLabel}"` + : `Edit binding for "${commandLabel}"`; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss pattern — not an interactive widget +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ + {title} + +
+ +
+ {/* Visual input — display only; actual capture happens via window listener */} +
+ {captured ? ( + formatComboLabel(captured) + ) : ( + + Press a key combination... + + )} +
+ {conflict ? ( + + Conflicts with "{conflict}" — press a different + combination + + ) : captured ? ( + + Press Enter to confirm, Escape to cancel + + ) : ( + + Press Escape to cancel + + )} +
+
+
); +} + +// --------------------------------------------------------------------------- +// Binding chip — single keycap group with click + right-click +// --------------------------------------------------------------------------- + +type RecordingMode = { type: "add" } | { type: "edit"; key: string } | null; + +interface BindingChipProps { + combo: string; + commandLabel: string; + canRemove: boolean; + canAddMore: boolean; + isAtDefault: boolean; + onStartRecording: (mode: RecordingMode) => void; + onRemove: () => void; + onReset: () => void; +} + +function BindingChip({ + combo, + commandLabel, + canRemove, + canAddMore, + isAtDefault, + onStartRecording, + onRemove, + onReset, +}: BindingChipProps) { + const parts = formatHotkeyParts(combo); return ( - {}} - tabIndex={0} - onKeyDown={handleKeyDown} - onBlur={onCancel} - className="h-[28px] min-w-[120px] cursor-text rounded-[6px] border border-(--accent-8) bg-(--accent-2) px-2 text-center text-(--accent-11) text-[12px] outline-none ring-(--accent-8) ring-1 placeholder:text-(--accent-11)" - /> + + + + + + + onStartRecording({ type: "edit", key: combo })} + > + Edit binding + + {canAddMore && ( + onStartRecording({ type: "add" })}> + Add another binding + + )} + + {canRemove ? ( + + Remove binding + + ) : ( + + + + Remove binding + + + + )} + {isAtDefault ? ( + + + + Reset to default + + + + ) : ( + + Reset to default + + )} + + ); } +// --------------------------------------------------------------------------- +// ShortcutRecorder — main export +// --------------------------------------------------------------------------- + interface ShortcutRecorderProps { id: ConfigurableShortcutId; + onRecordingChange?: (recording: boolean) => void; } -export function ShortcutRecorder({ id }: ShortcutRecorderProps) { - const [recording, setRecording] = useState(false); +export function ShortcutRecorder({ + id, + onRecordingChange, +}: ShortcutRecorderProps) { + const [recordingMode, setRecordingMode] = useState(null); const customs = useKeybindingsStore((s) => s.customKeybindings[id] ?? []); - const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); const removeKeybinding = useKeybindingsStore((s) => s.removeKeybinding); const resetShortcut = useKeybindingsStore((s) => s.resetShortcut); + const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); const hasCustom = customs.length > 0; const shortcutEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === id); - const handleCapture = useCallback( - (combo: string) => { - const conflict = findConflict(combo, id); - if (conflict) { - const conflictEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === conflict); - toast.error( - `Already used by "${conflictEntry?.description ?? conflict}"`, - ); - setRecording(false); - return; + // The effective list of individual binding strings to render as chips. + // When customised: use custom array. When at default: split the default + // string (e.g. "mod+n,mod+t" → ["mod+n","mod+t"]) so each chip is independent. + const defaultBindings = splitBindings(DEFAULT_KEYBINDINGS[id]); + const effectiveBindings = hasCustom ? customs : defaultBindings; + // canAddMore is based on custom count only — defaults don't consume the budget. + // A user can always add a first custom binding even if there are 2 defaults. + const canAddMore = customs.length < MAX_CUSTOM_BINDINGS; + + const startRecording = useCallback( + (mode: RecordingMode) => { + setRecordingMode(mode); + onRecordingChange?.(true); + }, + [onRecordingChange], + ); + + const stopRecording = useCallback(() => { + setRecordingMode(null); + onRecordingChange?.(false); + }, [onRecordingChange]); + + // Removing a default binding: store the remaining defaults as custom bindings. + const handleRemoveDefault = useCallback( + (key: string) => { + const remaining = defaultBindings.filter((k) => k !== key); + resetShortcut(id); + for (const k of remaining) { + addKeybinding(id, k); } - addKeybinding(id, combo); - setRecording(false); }, - [id, addKeybinding], + [id, defaultBindings, resetShortcut, addKeybinding], ); if (!shortcutEntry) return null; + const commandLabel = shortcutEntry.description; + const isAtDefault = !hasCustom; + return ( - - {recording ? ( - setRecording(false)} + <> + {recordingMode !== null && ( + - ) : hasCustom ? ( - customs.map((key, i) => ( - - {i > 0 && ( - - or - - )} - - {formatHotkeyParts(key).map((part) => ( - - ))} - - - - )) - ) : ( - - )} - - {!recording && ( - - )} - - {hasCustom && !recording && ( - )} - - ); -} - -function DefaultKeys({ - shortcutEntry, -}: { - shortcutEntry: (typeof KEYBOARD_SHORTCUTS)[number]; -}) { - const primaryParts = formatHotkeyParts(shortcutEntry.keys); - const alternateParts = shortcutEntry.alternateKeys - ? formatHotkeyParts(shortcutEntry.alternateKeys) - : null; - return ( - - - {primaryParts.map((part) => ( - - ))} - - {alternateParts && ( - <> - - or - - - {alternateParts.map((part) => ( - - ))} - - - )} - + {/* Horizontal scroll container — hides overflow without showing a scrollbar */} +
+ + {effectiveBindings.map((key, i) => ( + + {i > 0 && ( + + or + + )} + 1} + canAddMore={canAddMore} + isAtDefault={isAtDefault} + onStartRecording={startRecording} + onRemove={ + hasCustom + ? () => removeKeybinding(id, key) + : () => handleRemoveDefault(key) + } + onReset={() => resetShortcut(id)} + /> + + ))} + +
+ ); } diff --git a/packages/ui/src/shell/keybindingsStore.test.ts b/packages/ui/src/shell/keybindingsStore.test.ts index 85c61aea3f..6c08250f0d 100644 --- a/packages/ui/src/shell/keybindingsStore.test.ts +++ b/packages/ui/src/shell/keybindingsStore.test.ts @@ -188,43 +188,107 @@ describe("keybindingsStore", () => { }); }); + describe("updateKeybinding", () => { + it("replaces only the edited key when there are existing custom bindings", () => { + useKeybindingsStore.setState({ + customKeybindings: { "new-task": ["ctrl+p", "ctrl+q"] }, + }); + useKeybindingsStore + .getState() + .updateKeybinding("new-task", "ctrl+p", "ctrl+x"); + expect( + useKeybindingsStore.getState().customKeybindings["new-task"], + ).toEqual(["ctrl+x", "ctrl+q"]); + }); + + it("when editing a default binding, copies all defaults and replaces only the target", () => { + // new-task has 2 defaults: mod+n and mod+t + useKeybindingsStore + .getState() + .updateKeybinding("new-task", "mod+n", "ctrl+x"); + expect( + useKeybindingsStore.getState().customKeybindings["new-task"], + ).toEqual(["ctrl+x", "mod+t"]); + }); + + it("when editing the only default binding, stores just the new key", () => { + useKeybindingsStore + .getState() + .updateKeybinding("command-menu", "mod+k", "ctrl+x"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual(["ctrl+x"]); + }); + }); + + describe("addKeybinding — max binding limit", () => { + it("does not add a third binding beyond the max of 2", () => { + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+p"); + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+q"); + useKeybindingsStore.getState().addKeybinding("command-menu", "ctrl+r"); + expect( + useKeybindingsStore.getState().customKeybindings["command-menu"], + ).toEqual(["ctrl+p", "ctrl+q"]); + }); + }); + describe("findConflict", () => { beforeEach(() => { useKeybindingsStore.setState({ customKeybindings: {} }); }); - it("returns null when no conflict exists", () => { - expect(findConflict("ctrl+z", "command-menu")).toBeNull(); + it("returns no conflict when key is unused", () => { + const result = findConflict("ctrl+z", "command-menu"); + expect(result.description).toBeNull(); }); - it("detects a conflict with a default binding on another shortcut", () => { - // mod+b is the default for toggle-left-sidebar - expect(findConflict("mod+b", "command-menu")).toBe("toggle-left-sidebar"); + it("detects a conflict with a configurable default binding", () => { + // mod+b is the default for toggle-left-sidebar (configurable) + const result = findConflict("mod+b", "command-menu"); + expect(result.id).toBe("toggle-left-sidebar"); + expect(result.isFixed).toBe(false); }); it("does not flag the excluded shortcut's own default as a conflict", () => { - // mod+k is command-menu's own default — should not conflict with itself - expect(findConflict("mod+k", "command-menu")).toBeNull(); + // mod+k is command-menu's own default + const result = findConflict("mod+k", "command-menu"); + expect(result.description).toBeNull(); }); it("detects a conflict within comma-separated default alternates", () => { // prev-task default includes "ctrl+shift+tab" as an alternate - expect(findConflict("ctrl+shift+tab", "command-menu")).toBe("prev-task"); + const result = findConflict("ctrl+shift+tab", "command-menu"); + expect(result.id).toBe("prev-task"); }); it("detects a conflict with a custom binding on another shortcut", () => { useKeybindingsStore.setState({ customKeybindings: { settings: ["ctrl+alt+s"] }, }); - expect(findConflict("ctrl+alt+s", "command-menu")).toBe("settings"); + const result = findConflict("ctrl+alt+s", "command-menu"); + expect(result.id).toBe("settings"); }); it("does not conflict with custom binding on the excluded shortcut itself", () => { useKeybindingsStore.setState({ customKeybindings: { "command-menu": ["ctrl+p"] }, }); - // ctrl+p is a custom binding on command-menu — assigning it to command-menu again is fine - expect(findConflict("ctrl+p", "command-menu")).toBeNull(); + const result = findConflict("ctrl+p", "command-menu"); + expect(result.description).toBeNull(); + }); + + it("detects mod+, conflict correctly despite comma in the key", () => { + // settings default is mod+, — the comma is part of the key, not a separator + const result = findConflict("mod+,", "command-menu"); + expect(result.id).toBe("settings"); + }); + + it("detects conflicts with non-configurable shortcuts", () => { + // editor-underline (mod+u) is non-configurable (Tiptap internal) and + // not used by any configurable shortcut + const result = findConflict("mod+u", "command-menu"); + expect(result.isFixed).toBe(true); + expect(result.description).toBeTruthy(); }); }); }); diff --git a/packages/ui/src/shell/keybindingsStore.ts b/packages/ui/src/shell/keybindingsStore.ts index 72ec0b05ff..fdbed5456e 100644 --- a/packages/ui/src/shell/keybindingsStore.ts +++ b/packages/ui/src/shell/keybindingsStore.ts @@ -2,15 +2,23 @@ import { CONFIGURABLE_SHORTCUT_IDS, type ConfigurableShortcutId, DEFAULT_KEYBINDINGS, + KEYBOARD_SHORTCUTS, } from "../features/command/keyboard-shortcuts"; import { electronStorage } from "./rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; +export const MAX_CUSTOM_BINDINGS = 2; + interface KeybindingsState { customKeybindings: Partial>; getKey: (id: ConfigurableShortcutId) => string; addKeybinding: (id: ConfigurableShortcutId, key: string) => void; + updateKeybinding: ( + id: ConfigurableShortcutId, + oldKey: string, + newKey: string, + ) => void; removeKeybinding: (id: ConfigurableShortcutId, key: string) => void; resetShortcut: (id: ConfigurableShortcutId) => void; resetAll: () => void; @@ -25,18 +33,59 @@ export function resolveKey( return DEFAULT_KEYBINDINGS[id]; } +/** + * Split a keybinding string by comma, but preserve commas that are part of a + * key combo (e.g. "mod+," must not be split at the trailing comma). + * A valid separator comma is one NOT immediately preceded by "+". + */ +export function splitBindings(keyStr: string): string[] { + // Split on commas that are not preceded by "+" + return keyStr + .split(/(? k.trim()) + .filter(Boolean); +} + +export interface ConflictResult { + id: ConfigurableShortcutId | null; + description: string | null; + /** true when the conflicting shortcut is not user-configurable */ + isFixed: boolean; +} + export function findConflict( newKey: string, excludeId: ConfigurableShortcutId, -): ConfigurableShortcutId | null { +): ConflictResult { const state = useKeybindingsStore.getState(); + + // Check configurable shortcuts first (with custom overrides applied) for (const id of CONFIGURABLE_SHORTCUT_IDS) { if (id === excludeId) continue; const keyStr = state.getKey(id); - const parts = keyStr.split(",").map((k) => k.trim()); - if (parts.includes(newKey)) return id; + const parts = splitBindings(keyStr); + if (parts.includes(newKey)) { + const entry = KEYBOARD_SHORTCUTS.find((s) => s.id === id); + return { id, description: entry?.description ?? id, isFixed: false }; + } + } + + // Check non-configurable shortcuts against their static default keys + for (const shortcut of KEYBOARD_SHORTCUTS) { + if ( + CONFIGURABLE_SHORTCUT_IDS.includes(shortcut.id as ConfigurableShortcutId) + ) + continue; + const parts = splitBindings(shortcut.keys); + if (shortcut.alternateKeys) { + parts.push(...splitBindings(shortcut.alternateKeys)); + } + if (parts.includes(newKey)) { + return { id: null, description: shortcut.description, isFixed: true }; + } } - return null; + + return { id: null, description: null, isFixed: false }; } export const useKeybindingsStore = create()( @@ -49,6 +98,7 @@ export const useKeybindingsStore = create()( addKeybinding: (id, key) => { const existing = get().customKeybindings[id] ?? []; if (existing.includes(key)) return; + if (existing.length >= MAX_CUSTOM_BINDINGS) return; set({ customKeybindings: { ...get().customKeybindings, @@ -57,6 +107,20 @@ export const useKeybindingsStore = create()( }); }, + updateKeybinding: (id, oldKey, newKey) => { + const existing = get().customKeybindings[id] ?? []; + // When editing a default binding, copy all defaults first so the other + // defaults are preserved — only the edited key gets replaced. + const base = + existing.length > 0 + ? existing + : splitBindings(DEFAULT_KEYBINDINGS[id]); + const updated = base.map((k) => (k === oldKey ? newKey : k)); + set({ + customKeybindings: { ...get().customKeybindings, [id]: updated }, + }); + }, + removeKeybinding: (id, key) => { const existing = get().customKeybindings[id] ?? []; const updated = existing.filter((k) => k !== key); From 9bdb7c05be7e9d354bc64bfe56d0649c2e686e69 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Sat, 23 May 2026 03:29:58 +0100 Subject: [PATCH 07/23] fix: address Greptile review; clean up comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deduplicate in updateKeybinding — conflict detection excludes the shortcut being edited so editing one binding to match another on the same shortcut could produce ["ctrl+q","ctrl+q"], duplicate React keys and broken chip reconciliation - Remove ArrowUp/Down gate around prompt-history navigation so custom non-arrow bindings (e.g. Ctrl+K) actually fire when pressed, not just when the physical key is an arrow - Remove obvious section-divider comments and redundant JSX labels (Header, Scrollable list, Sticky footer); keep non-obvious rationale comments (window-level capture, backdrop dismiss, canAddMore budget, dedup note, ArrowKey gate explanation) --- .../message-editor/tiptap/useTiptapEditor.ts | 111 +++++++++--------- .../src/primitives/KeyboardShortcutsSheet.tsx | 3 - .../ui/src/primitives/ShortcutRecorder.tsx | 24 +--- packages/ui/src/shell/keybindingsStore.ts | 8 +- 4 files changed, 60 insertions(+), 86 deletions(-) diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 1429e9c2e5..fa3c932063 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,4 +1,4 @@ -import { +import { eventToCombo, formatHotkey, tiptapEventToCombo, @@ -302,23 +302,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } - if ( - (event.key === "ArrowUp" || event.key === "ArrowDown") && - // Only navigate prompt history when the input is empty, so arrow - // keys (and Shift+Arrow selection) behave normally while editing. - !event.shiftKey - ) { - const historyGetter = getPromptHistoryRef.current; - if (!taskId && !historyGetter) return false; - - const currentText = view.state.doc.textContent; - const isEmpty = !currentText.trim(); - -<<<<<<< HEAD:packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts - const history = historyGetter?.() ?? []; - - if (event.key === "ArrowUp" && isEmpty) { -======= + // Resolve prompt-history bindings before the ArrowKey gate so custom + // non-arrow bindings (e.g. Ctrl+K) still trigger history navigation. + { const keybindings = useKeybindingsStore.getState(); const tiptapCombo = tiptapEventToCombo(event); const forcePrev = @@ -331,56 +317,67 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { splitBindings(keybindings.getKey("prompt-history-next")).includes( tiptapCombo, ); - const history = historyGetter?.() ?? []; if ( - event.key === "ArrowUp" && - (forcePrev || isEmpty || isAtStart) + forcePrev || + forceNext || + event.key === "ArrowUp" || + event.key === "ArrowDown" ) { ->>>>>>> 13027004 (feat: make prompt-history shortcuts configurable; update E2E tests):apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts - if (taskId) { - const queuedContent = - sessionStoreSetters.dequeueMessagesAsText(taskId); - if (queuedContent !== null && queuedContent !== undefined) { + const historyGetter = getPromptHistoryRef.current; + if (!taskId && !historyGetter) return false; + + const currentText = view.state.doc.textContent; + const isEmpty = !currentText.trim(); + const { from } = view.state.selection; + const isAtStart = from === 1; + const isAtEnd = from === view.state.doc.content.size - 1; + const history = historyGetter?.() ?? []; + + if ( + forcePrev || + (event.key === "ArrowUp" && (isEmpty || isAtStart)) + ) { + if (taskId) { + const queuedContent = + sessionStoreSetters.dequeueMessagesAsText(taskId); + if (queuedContent !== null && queuedContent !== undefined) { + event.preventDefault(); + view.dispatch( + view.state.tr + .delete(1, view.state.doc.content.size - 1) + .insertText(queuedContent, 1), + ); + return true; + } + } + + const newText = historyActions.navigateUp(history, currentText); + if (newText !== null) { event.preventDefault(); view.dispatch( view.state.tr .delete(1, view.state.doc.content.size - 1) - .insertText(queuedContent, 1), + .insertText(newText, 1), ); return true; } } - const newText = historyActions.navigateUp(history, currentText); - if (newText !== null) { - event.preventDefault(); - view.dispatch( - view.state.tr - .delete(1, view.state.doc.content.size - 1) - .insertText(newText, 1), - ); - return true; - } - } - -<<<<<<< HEAD:packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts - if (event.key === "ArrowDown" && isEmpty) { -======= - if ( - event.key === "ArrowDown" && - (forceNext || isEmpty || isAtEnd) - ) { ->>>>>>> 13027004 (feat: make prompt-history shortcuts configurable; update E2E tests):apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts - const newText = historyActions.navigateDown(history); - if (newText !== null) { - event.preventDefault(); - view.dispatch( - view.state.tr - .delete(1, view.state.doc.content.size - 1) - .insertText(newText, 1), - ); - return true; + if ( + forceNext || + (event.key === "ArrowDown" && (isEmpty || isAtEnd)) + ) { + const newText = historyActions.navigateDown(history); + if (newText !== null) { + event.preventDefault(); + view.dispatch( + view.state.tr + .delete(1, view.state.doc.content.size - 1) + .insertText(newText, 1), + ); + return true; + } } } } @@ -616,7 +613,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (enableBashMode && isBashModeText(text)) { // Bash mode requires immediate execution, can't be queued. - // Intentionally bypasses onBeforeSubmit — bash commands run inline and + // Intentionally bypasses onBeforeSubmit — bash commands run inline and // cannot be deferred the way normal prompts can. if (isLoading) { toast.error("Cannot run shell commands while agent is generating"); diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index 969a109c58..8679057ea7 100644 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -37,7 +37,6 @@ export function KeyboardShortcutsSheet({ onEscapeKeyDown={(e) => e.preventDefault()} className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden" > - {/* Header */} + )} + + ); +} + export function KeyboardShortcutsSheet({ open, onOpenChange, }: KeyboardShortcutsSheetProps) { - useHotkeys("escape", () => onOpenChange(false), { - enabled: open, + const [searchText, setSearchText] = useState(""); + const [comboSearch, setComboSearch] = useState(null); + + const clearSearch = useCallback(() => { + setSearchText(""); + setComboSearch(null); + }, []); + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen) { + setSearchText(""); + setComboSearch(null); + } + onOpenChange(newOpen); + }, + [onOpenChange], + ); + + // Escape closes the modal only when the search bar has nothing to clear. + useHotkeys("escape", () => handleOpenChange(false), { + enabled: open && !searchText && !comboSearch, enableOnContentEditable: true, enableOnFormTags: true, preventDefault: true, }); return ( - + e.preventDefault()} @@ -41,15 +238,26 @@ export function KeyboardShortcutsSheet({ + + - + {/* Bottom padding so list content doesn't sit behind the sticky footer */} @@ -113,20 +321,55 @@ function ShortcutsHeader() { ); } -export function KeyboardShortcutsList() { - const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); +interface KeyboardShortcutsListProps { + searchText?: string; + comboSearch?: ComboSearch | null; +} + +export function KeyboardShortcutsList({ + searchText = "", + comboSearch = null, +}: KeyboardShortcutsListProps = {}) { + const customKeybindings = useKeybindingsStore((s) => s.customKeybindings); - const categoryOrder: ShortcutCategory[] = [ - "general", - "navigation", - "panels", - "editor", - ]; + const filteredByCategory = useMemo(() => { + const base = getShortcutsByCategory(); + if (!searchText && !comboSearch) return base; + + const result: Record = { + general: [], + navigation: [], + panels: [], + editor: [], + }; + for (const category of CATEGORY_ORDER) { + result[category] = base[category].filter((shortcut) => + comboSearch + ? shortcutMatchesCombo(shortcut, customKeybindings, comboSearch) + : shortcutMatchesText(shortcut, searchText), + ); + } + return result; + }, [searchText, comboSearch, customKeybindings]); + + const hasResults = CATEGORY_ORDER.some( + (c) => filteredByCategory[c].length > 0, + ); + + if (!hasResults && (searchText || comboSearch)) { + return ( + + + No shortcuts found + + + ); + } return ( - {categoryOrder.map((category) => { - const shortcuts = shortcutsByCategory[category]; + {CATEGORY_ORDER.map((category) => { + const shortcuts = filteredByCategory[category]; if (shortcuts.length === 0) return null; const uniqueShortcuts = shortcuts.reduce( From 229e0f3f5c5605dcdf1158bc62ddbfe7b2d49346 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 15:59:12 +0100 Subject: [PATCH 09/23] fix: polish search bar UX in shortcuts modal --- .../src/primitives/KeyboardShortcutsSheet.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index bc13ad29ed..9f0beac53f 100644 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -105,6 +105,8 @@ function ShortcutsSearchBar({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + const inComboMode = comboSearch !== null; + if (e.key === "Escape") { if (comboSearch) { e.preventDefault(); @@ -119,11 +121,26 @@ function ShortcutsSearchBar({ return; } + // Backspace in combo mode clears the combo search. + if (e.key === "Backspace" && inComboMode) { + e.preventDefault(); + onComboChange(null); + return; + } + const result = eventToSearchCombo(e.nativeEvent); if (result) { e.preventDefault(); if (!result.isPartial) onTextChange(""); onComboChange(result); + return; + } + + // Typing a regular character in combo mode exits combo mode and starts text search. + if (inComboMode && e.key.length === 1) { + e.preventDefault(); + onComboChange(null); + onTextChange(e.key); } }, [comboSearch, searchText, onComboChange, onTextChange], @@ -166,7 +183,7 @@ function ShortcutsSearchBar({ }} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} - placeholder="Search by name or press a key combo…" + placeholder={isComboMode ? "" : "Search by name or press a key combo…"} type="text" value={isComboMode ? "" : searchText} /> @@ -234,14 +251,14 @@ export function KeyboardShortcutsSheet({ onEscapeKeyDown={(e) => e.preventDefault()} className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden" > - + @@ -303,7 +320,7 @@ function ShortcutsHeader() { const triggerParts = formatHotkeyParts(shortcutsKey); return ( - + Keyboard Combos From 88b81d677a9cb8ae596b93f4bf8fefbd4fd2158e Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 16:23:58 +0100 Subject: [PATCH 10/23] fix: Escape always closes modal, not clears search --- .../src/primitives/KeyboardShortcutsSheet.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index 9f0beac53f..99069cb47b 100644 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -107,20 +107,6 @@ function ShortcutsSearchBar({ (e: React.KeyboardEvent) => { const inComboMode = comboSearch !== null; - if (e.key === "Escape") { - if (comboSearch) { - e.preventDefault(); - onComboChange(null); - return; - } - if (searchText) { - e.preventDefault(); - onTextChange(""); - return; - } - return; - } - // Backspace in combo mode clears the combo search. if (e.key === "Backspace" && inComboMode) { e.preventDefault(); @@ -143,7 +129,7 @@ function ShortcutsSearchBar({ onTextChange(e.key); } }, - [comboSearch, searchText, onComboChange, onTextChange], + [comboSearch, onComboChange, onTextChange], ); const handleKeyUp = useCallback( @@ -236,9 +222,8 @@ export function KeyboardShortcutsSheet({ [onOpenChange], ); - // Escape closes the modal only when the search bar has nothing to clear. useHotkeys("escape", () => handleOpenChange(false), { - enabled: open && !searchText && !comboSearch, + enabled: open, enableOnContentEditable: true, enableOnFormTags: true, preventDefault: true, From dae4befe936c3356005998201b3cd03f42804e0b Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 29 Jun 2026 10:29:23 +0100 Subject: [PATCH 11/23] feat(settings): wire configurable shortcuts into settings page and Ctrl+/ modal --- .../ui/src/features/settings/sections/ShortcutsSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx index 90b4751853..fa0c158174 100644 --- a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx +++ b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx @@ -1,4 +1,4 @@ -import { KeyboardShortcutsList } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { KeyboardShortcutsList } from "@posthog/ui/primitives/KeyboardShortcutsSheet"; export function ShortcutsSettings() { return ; From c1f2d5d898d044f047d95a971f3252bf0be35dcb Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 29 Jun 2026 16:12:46 +0100 Subject: [PATCH 12/23] refactor: wire enhanced KeyboardShortcutsSheet into features/command/ The configurable shortcuts logic was built in primitives/ but the app always imported from features/command/. Copy the enhanced version (ShortcutRecorder rows, search bar, reset-all footer) into features/command/ and point ShortcutsSettings at that canonical location. --- .../command/KeyboardShortcutsSheet.tsx | 413 +++++++++++++++--- .../settings/sections/ShortcutsSettings.tsx | 2 +- 2 files changed, 345 insertions(+), 70 deletions(-) diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index fd919b5081..df26e7eef5 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -1,59 +1,228 @@ +import { Keycap } from "@posthog/ui/primitives/Keycap"; +import { ShortcutRecorder } from "@posthog/ui/primitives/ShortcutRecorder"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { useShortcut } from "@posthog/ui/primitives/hooks/useShortcut"; +import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { CATEGORY_LABELS, + type ConfigurableShortcutId, formatHotkeyParts, getShortcutsByCategory, + type KeyboardShortcut, type ShortcutCategory, } from "@posthog/ui/features/command/keyboard-shortcuts"; -import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; -import { useMemo, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; +import { + resolveKey, + splitBindings, + useKeybindingsStore, +} from "@posthog/ui/shell/keybindingsStore"; + +interface KeyboardShortcutsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface ComboSearch { + combo: string; + isPartial: boolean; +} + +const CATEGORY_ORDER: ShortcutCategory[] = [ + "general", + "navigation", + "panels", + "editor", +]; + +function eventToSearchCombo(e: KeyboardEvent): ComboSearch | null { + const isModifierKey = ["Meta", "Control", "Shift", "Alt"].includes(e.key); + const hasModifier = e.ctrlKey || e.metaKey || e.altKey || e.shiftKey; + if (!hasModifier) return null; -function Keycap({ label, size = "md" }: { label: string; size?: "sm" | "md" }) { - const [pressed, setPressed] = useState(false); - const isSmall = size === "sm"; - const minW = isSmall ? "22px" : "28px"; - const h = isSmall ? "22px" : "28px"; - const fontSize = isSmall ? "11px" : "13px"; - const shadowSize = isSmall ? "2px" : "3px"; + const parts: string[] = []; + if (e.metaKey || e.ctrlKey) parts.push("mod"); + if (e.shiftKey) parts.push("shift"); + if (e.altKey) parts.push("alt"); + if (!isModifierKey) { + parts.push(e.key.toLowerCase().replace(/^arrow/, "")); + return { combo: parts.join("+"), isPartial: false }; + } + return parts.length > 0 ? { combo: parts.join("+"), isPartial: true } : null; +} + +function shortcutMatchesText( + shortcut: KeyboardShortcut, + query: string, +): boolean { + const q = query.toLowerCase(); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation - setPressed(true)} - onMouseUp={() => setPressed(false)} - onMouseLeave={() => setPressed(false)} - style={{ - minWidth: minW, - height: h, - fontSize, - fontFamily: "system-ui, -apple-system, sans-serif", - lineHeight: 1, - borderBottomWidth: pressed ? "1px" : shadowSize, - borderBottomColor: "var(--gray-7)", - transform: pressed - ? `translateY(${isSmall ? "1px" : "2px"})` - : "translateY(0)", - transition: - "transform 80ms ease-out, border-bottom-width 80ms ease-out", - }} - className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)" - > - {label} - + shortcut.description.toLowerCase().includes(q) || + (shortcut.context?.toLowerCase().includes(q) ?? false) ); } -interface KeyboardShortcutsSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; +function shortcutMatchesCombo( + shortcut: KeyboardShortcut, + customKeybindings: Partial>, + { combo, isPartial }: ComboSearch, +): boolean { + let bindingStr: string; + if (shortcut.configurable) { + bindingStr = resolveKey( + customKeybindings, + shortcut.id as ConfigurableShortcutId, + ); + } else { + bindingStr = shortcut.keys; + if (shortcut.alternateKeys) bindingStr += `,${shortcut.alternateKeys}`; + } + return splitBindings(bindingStr).some((b) => + isPartial ? b === combo || b.startsWith(`${combo}+`) : b === combo, + ); +} + +interface ShortcutsSearchBarProps { + searchText: string; + comboSearch: ComboSearch | null; + onTextChange: (text: string) => void; + onComboChange: (combo: ComboSearch | null) => void; + onClear: () => void; +} + +function ShortcutsSearchBar({ + searchText, + comboSearch, + onTextChange, + onComboChange, + onClear, +}: ShortcutsSearchBarProps) { + const isComboMode = comboSearch !== null; + const parts = comboSearch ? formatHotkeyParts(comboSearch.combo) : []; + const hasContent = isComboMode || searchText.length > 0; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const inComboMode = comboSearch !== null; + + // Backspace in combo mode clears the combo search. + if (e.key === "Backspace" && inComboMode) { + e.preventDefault(); + onComboChange(null); + return; + } + + const result = eventToSearchCombo(e.nativeEvent); + if (result) { + e.preventDefault(); + if (!result.isPartial) onTextChange(""); + onComboChange(result); + return; + } + + // Typing a regular character in combo mode exits combo mode and starts text search. + if (inComboMode && e.key.length === 1) { + e.preventDefault(); + onComboChange(null); + onTextChange(e.key); + } + }, + [comboSearch, onComboChange, onTextChange], + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (!comboSearch?.isPartial) return; + onComboChange(eventToSearchCombo(e.nativeEvent)); + }, + [comboSearch, onComboChange], + ); + + return ( +
+
+ +
+ + { + if (!isComboMode) onTextChange(e.target.value); + }} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + placeholder={isComboMode ? "" : "Search by name or press a key combo…"} + type="text" + value={isComboMode ? "" : searchText} + /> + + {isComboMode && ( +
+ {parts.map((part) => ( + + ))} + {comboSearch.isPartial && ( + … + )} +
+ )} + + {hasContent && ( + + )} +
+ ); } export function KeyboardShortcutsSheet({ open, onOpenChange, }: KeyboardShortcutsSheetProps) { - useHotkeys("escape", () => onOpenChange(false), { + const [searchText, setSearchText] = useState(""); + const [comboSearch, setComboSearch] = useState(null); + + const clearSearch = useCallback(() => { + setSearchText(""); + setComboSearch(null); + }, []); + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen) { + setSearchText(""); + setComboSearch(null); + } + onOpenChange(newOpen); + }, + [onOpenChange], + ); + + useHotkeys("escape", () => handleOpenChange(false), { enabled: open, enableOnContentEditable: true, enableOnFormTags: true, @@ -61,36 +230,82 @@ export function KeyboardShortcutsSheet({ }); return ( - + e.preventDefault()} - className="max-h-[80vh] overflow-hidden" + className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden" > - + - - + + + + + {/* Bottom padding so list content doesn't sit behind the sticky footer */} + + + ); } +function ResetAllFooter() { + const hasCustomBindings = useKeybindingsStore((s) => + Object.keys(s.customKeybindings).some( + (k) => + (s.customKeybindings[k as ConfigurableShortcutId]?.length ?? 0) > 0, + ), + ); + const resetAll = useKeybindingsStore((s) => s.resetAll); + + if (!hasCustomBindings) return null; + + return ( + + + + ); +} + function ShortcutsHeader() { - const triggerParts = formatHotkeyParts("mod+/"); + const shortcutsKey = useShortcut("shortcuts"); + const triggerParts = formatHotkeyParts(shortcutsKey); return ( - + Keyboard Combos @@ -108,20 +323,55 @@ function ShortcutsHeader() { ); } -export function KeyboardShortcutsList() { - const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); +interface KeyboardShortcutsListProps { + searchText?: string; + comboSearch?: ComboSearch | null; +} + +export function KeyboardShortcutsList({ + searchText = "", + comboSearch = null, +}: KeyboardShortcutsListProps = {}) { + const customKeybindings = useKeybindingsStore((s) => s.customKeybindings); + + const filteredByCategory = useMemo(() => { + const base = getShortcutsByCategory(); + if (!searchText && !comboSearch) return base; - const categoryOrder: ShortcutCategory[] = [ - "general", - "navigation", - "panels", - "editor", - ]; + const result: Record = { + general: [], + navigation: [], + panels: [], + editor: [], + }; + for (const category of CATEGORY_ORDER) { + result[category] = base[category].filter((shortcut) => + comboSearch + ? shortcutMatchesCombo(shortcut, customKeybindings, comboSearch) + : shortcutMatchesText(shortcut, searchText), + ); + } + return result; + }, [searchText, comboSearch, customKeybindings]); + + const hasResults = CATEGORY_ORDER.some( + (c) => filteredByCategory[c].length > 0, + ); + + if (!hasResults && (searchText || comboSearch)) { + return ( + + + No shortcuts found + + + ); + } return ( - {categoryOrder.map((category) => { - const shortcuts = shortcutsByCategory[category]; + {CATEGORY_ORDER.map((category) => { + const shortcuts = filteredByCategory[category]; if (shortcuts.length === 0) return null; const uniqueShortcuts = shortcuts.reduce( @@ -148,14 +398,30 @@ export function KeyboardShortcutsList() { key={shortcut.id} align="center" justify="between" + gap="3" px="3" - className="border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" + className="group border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" > - {shortcut.description} - + + {shortcut.description} + {shortcut.context && ( + + {shortcut.context} + + )} + +
+ {shortcut.configurable ? ( + + ) : ( + + )} +
))}
@@ -168,7 +434,6 @@ export function KeyboardShortcutsList() { function SingleShortcutKeys({ keys }: { keys: string }) { const parts = formatHotkeyParts(keys); - return ( {parts.map((part) => ( @@ -185,11 +450,7 @@ function ShortcutKeys({ keys: string; alternateKeys?: string; }) { - if (!alternateKeys) { - return ; - } - - return ( + const inner = alternateKeys ? ( @@ -197,5 +458,19 @@ function ShortcutKeys({ + ) : ( + + ); + + return ( + +
+ {inner} +
+
); } diff --git a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx index fa0c158174..90b4751853 100644 --- a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx +++ b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx @@ -1,4 +1,4 @@ -import { KeyboardShortcutsList } from "@posthog/ui/primitives/KeyboardShortcutsSheet"; +import { KeyboardShortcutsList } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; export function ShortcutsSettings() { return ; From 2cb010496e39c4728852cb02955362e13fa5a64e Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 29 Jun 2026 16:52:34 +0100 Subject: [PATCH 13/23] =?UTF-8?q?feat(settings):=20match=20shortcuts=20set?= =?UTF-8?q?tings=20page=20with=20modal=20=E2=80=94=20search=20+=20reset=20?= =?UTF-8?q?footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/KeyboardShortcutsSheet.tsx | 24 +++++++++++++++++++ .../settings/sections/ShortcutsSettings.tsx | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index df26e7eef5..1a3fab4e3f 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -199,6 +199,30 @@ function ShortcutsSearchBar({ ); } +export function KeyboardShortcutsContent() { + const [searchText, setSearchText] = useState(""); + const [comboSearch, setComboSearch] = useState(null); + + const clearSearch = useCallback(() => { + setSearchText(""); + setComboSearch(null); + }, []); + + return ( + <> + + + + + ); +} + export function KeyboardShortcutsSheet({ open, onOpenChange, diff --git a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx index 90b4751853..0f1081013b 100644 --- a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx +++ b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx @@ -1,5 +1,5 @@ -import { KeyboardShortcutsList } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { KeyboardShortcutsContent } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; export function ShortcutsSettings() { - return ; + return ; } From 1312e03f88f3bae619f50bf9f35b6f1f27b03180 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 29 Jun 2026 19:11:38 +0100 Subject: [PATCH 14/23] fix: disable global hotkeys while shortcuts sheet is open so combo-search works --- packages/ui/src/shell/GlobalEventHandlers.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index 5188a5ddc3..48aac97d01 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -26,6 +26,7 @@ import { import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; +import { useShortcutsSheetStore } from "@posthog/ui/shell/shortcutsSheetStore"; import { logger } from "@posthog/ui/shell/logger"; import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; import { isMac } from "@posthog/ui/utils/platform"; @@ -46,6 +47,7 @@ export function GlobalEventHandlers({ const trpcReact = useHostTRPC(); const sessionService = useService(SESSION_SERVICE); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); + const shortcutsSheetOpen = useShortcutsSheetStore((s) => s.isOpen); const openSettingsDialog = openSettings; const view = useAppView(); const goBack = goBackInHistory; @@ -153,6 +155,7 @@ export function GlobalEventHandlers({ enableOnFormTags: true, enableOnContentEditable: true, preventDefault: true, + enabled: !shortcutsSheetOpen, } as const; const commandMenuKey = useShortcut("command-menu"); @@ -170,7 +173,7 @@ export function GlobalEventHandlers({ useHotkeys(commandMenuKey, onToggleCommandMenu, { ...globalOptions, - enabled: !commandMenuOpen, + enabled: !commandMenuOpen && !shortcutsSheetOpen, }); useHotkeys(newTaskKey, handleFocusTaskMode, globalOptions); useHotkeys(settingsKey, handleOpenSettings, globalOptions); @@ -185,7 +188,10 @@ export function GlobalEventHandlers({ useHotkeys(toggleLeftSidebarKey, toggleLeftSidebar, globalOptions); useHotkeys(toggleReviewPanelKey, handleToggleReview, globalOptions); - useHotkeys(shortcutsSheetKey, onToggleShortcutsSheet, globalOptions); + useHotkeys(shortcutsSheetKey, onToggleShortcutsSheet, { + ...globalOptions, + enabled: true, + }); useHotkeys(inboxKey, navigateToInbox, globalOptions); useHotkeys(prevTaskKey, handlePrevTask, globalOptions, [handlePrevTask]); useHotkeys(nextTaskKey, handleNextTask, globalOptions, [handleNextTask]); @@ -195,7 +201,7 @@ export function GlobalEventHandlers({ handleToggleFocus, { ...globalOptions, - enabled: !!currentTaskId && isWorktreeTask, + enabled: !!currentTaskId && isWorktreeTask && !shortcutsSheetOpen, }, [handleToggleFocus], ); From 8c2d1f6b5bc5e15f26bbd4486f282af5b86c1ee7 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 29 Jun 2026 19:48:56 +0100 Subject: [PATCH 15/23] =?UTF-8?q?fix(shortcuts):=20polish=20context=20menu?= =?UTF-8?q?=20=E2=80=94=20no=20separator,=20no=20red,=20reorder=20+=20disa?= =?UTF-8?q?ble-with-tooltip=20instead=20of=20hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove divider between edit actions and destructive actions - Remove red color from Remove binding (non-destructive; easily reversible) - Move Reset to default before Remove binding so Remove is last - Show Add another binding as disabled-with-tooltip at max capacity instead of hiding it - Add sticky footer to shortcuts settings page --- .../command/KeyboardShortcutsSheet.tsx | 4 ++- .../ui/src/primitives/ShortcutRecorder.tsx | 28 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index 1a3fab4e3f..8fdefee714 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -218,7 +218,9 @@ export function KeyboardShortcutsContent() { onClear={clearSearch} /> - +
+ +
); } diff --git a/packages/ui/src/primitives/ShortcutRecorder.tsx b/packages/ui/src/primitives/ShortcutRecorder.tsx index 1b9be95095..e54237f0f2 100644 --- a/packages/ui/src/primitives/ShortcutRecorder.tsx +++ b/packages/ui/src/primitives/ShortcutRecorder.tsx @@ -198,24 +198,18 @@ function BindingChip({ > Edit binding - {canAddMore && ( + {canAddMore ? ( onStartRecording({ type: "add" })}> Add another binding - )} - - {canRemove ? ( - - Remove binding - ) : ( - Remove binding + Add another binding @@ -233,6 +227,22 @@ function BindingChip({ Reset to default )} + {canRemove ? ( + + Remove binding + + ) : ( + + + + Remove binding + + + + )} ); From e73ec1544bb9ff74758302bd72d173b435bc885a Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 30 Jun 2026 15:49:27 +0100 Subject: [PATCH 16/23] =?UTF-8?q?refactor(shortcuts):=20inline=20recording?= =?UTF-8?q?,=20pencil=20icon,=20fix=20Mac=20Ctrl=E2=86=92mod=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fullscreen ShortcutRecordingModal with inline InlineRecorder that captures keys directly in the chip row — no backdrop, no modal - Live partial preview: modifier-only presses show a pulsing partial combo (e.g. Ctrl… then Ctrl+Shift+K) before the final key is pressed - Pencil icon appears on chip hover; click chip or pencil to edit inline; click outside or Escape to cancel - Remove Keycap press animation — chips are purely visual, no confusing press effect on non-editable shortcuts - Fix eventToCombo Mac bug: Ctrl key on Mac was mapped to mod (⌘) instead of ctrl (⌃); same fix in tiptapEventToCombo and eventToSearchCombo - Allow shift+non-printable combos (shift+up/down) in the recorder so prompt-history defaults can be re-recorded; block shift+letter to avoid conflicts with normal typing - Move ShortcutRecorder from primitives/ into features/command/ - Delete dead primitives/KeyboardShortcutsSheet.tsx - Fix Windows encoding corruption in useTiptapEditor.ts comment --- .../command/KeyboardShortcutsSheet.tsx | 30 +- .../src/features/command/ShortcutRecorder.tsx | 375 ++++++++++++++ .../features/command/keyboard-shortcuts.ts | 55 +- .../message-editor/tiptap/useTiptapEditor.ts | 20 +- .../src/primitives/KeyboardShortcutsSheet.tsx | 476 ------------------ packages/ui/src/primitives/Keycap.tsx | 17 +- .../ui/src/primitives/ShortcutRecorder.tsx | 345 ------------- 7 files changed, 448 insertions(+), 870 deletions(-) create mode 100644 packages/ui/src/features/command/ShortcutRecorder.tsx delete mode 100644 packages/ui/src/primitives/KeyboardShortcutsSheet.tsx delete mode 100644 packages/ui/src/primitives/ShortcutRecorder.tsx diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index 8fdefee714..d0089ea07f 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -1,10 +1,3 @@ -import { Keycap } from "@posthog/ui/primitives/Keycap"; -import { ShortcutRecorder } from "@posthog/ui/primitives/ShortcutRecorder"; -import { Tooltip } from "@posthog/ui/primitives/Tooltip"; -import { useShortcut } from "@posthog/ui/primitives/hooks/useShortcut"; -import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; -import { useCallback, useMemo, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { CATEGORY_LABELS, type ConfigurableShortcutId, @@ -13,11 +6,19 @@ import { type KeyboardShortcut, type ShortcutCategory, } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useShortcut } from "@posthog/ui/primitives/hooks/useShortcut"; +import { Keycap } from "@posthog/ui/primitives/Keycap"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { resolveKey, splitBindings, useKeybindingsStore, } from "@posthog/ui/shell/keybindingsStore"; +import { isMac } from "@posthog/ui/utils/platform"; +import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { ShortcutRecorder } from "./ShortcutRecorder"; interface KeyboardShortcutsSheetProps { open: boolean; @@ -41,10 +42,12 @@ function eventToSearchCombo(e: KeyboardEvent): ComboSearch | null { const hasModifier = e.ctrlKey || e.metaKey || e.altKey || e.shiftKey; if (!hasModifier) return null; - const parts: string[] = []; - if (e.metaKey || e.ctrlKey) parts.push("mod"); - if (e.shiftKey) parts.push("shift"); - if (e.altKey) parts.push("alt"); + const partSet = new Set(); + if (e.metaKey) partSet.add("mod"); + if (e.ctrlKey) partSet.add(isMac ? "ctrl" : "mod"); + if (e.shiftKey) partSet.add("shift"); + if (e.altKey) partSet.add("alt"); + const parts = [...partSet]; if (!isModifierKey) { parts.push(e.key.toLowerCase().replace(/^arrow/, "")); @@ -217,7 +220,10 @@ export function KeyboardShortcutsContent() { onComboChange={setComboSearch} onClear={clearSearch} /> - +
diff --git a/packages/ui/src/features/command/ShortcutRecorder.tsx b/packages/ui/src/features/command/ShortcutRecorder.tsx new file mode 100644 index 0000000000..d9fd9d5910 --- /dev/null +++ b/packages/ui/src/features/command/ShortcutRecorder.tsx @@ -0,0 +1,375 @@ +import { Keycap } from "@posthog/ui/primitives/Keycap"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { + findConflict, + MAX_CUSTOM_BINDINGS, + splitBindings, + useKeybindingsStore, +} from "@posthog/ui/shell/keybindingsStore"; +import { ContextMenu, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ConfigurableShortcutId, + DEFAULT_KEYBINDINGS, + formatHotkeyParts, + KEYBOARD_SHORTCUTS, + recordingEventToCombo, +} from "./keyboard-shortcuts"; + +// Pencil SVG icon (16×16) +function PencilIcon() { + return ( + + ); +} + +interface InlineRecorderProps { + shortcutId: ConfigurableShortcutId; + onSave: (combo: string) => void; + onCancel: () => void; +} + +function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { + const [captured, setCaptured] = useState(null); + const [partial, setPartial] = useState(null); + const [conflict, setConflict] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + onCancel(); + return; + } + if (e.key === "Enter") { + if (captured && !conflict) onSave(captured); + return; + } + + const result = recordingEventToCombo(e); + if (!result) return; + + if (result.isPartial) { + setPartial(result.combo); + setCaptured(null); + setConflict(null); + } else { + setPartial(null); + const { description } = findConflict(result.combo, shortcutId); + setCaptured(result.combo); + setConflict(description); + } + }; + + const keyupHandler = (e: KeyboardEvent) => { + if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { + setPartial(null); + } + }; + + window.addEventListener("keydown", handler, { capture: true }); + window.addEventListener("keyup", keyupHandler); + return () => { + window.removeEventListener("keydown", handler, { capture: true }); + window.removeEventListener("keyup", keyupHandler); + }; + }, [captured, conflict, shortcutId, onSave, onCancel]); + + // Click outside to cancel + useEffect(() => { + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + onCancel(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onCancel]); + + const displayCombo = partial ?? captured; + const displayParts = displayCombo ? formatHotkeyParts(displayCombo) : null; + + return ( +
+
+ {displayParts ? ( + + {displayParts.map((part) => ( + + ))} + {partial && ( + + … + + )} + + ) : ( + Press keys… + )} +
+ {conflict ? ( + + Conflicts with "{conflict}" + + ) : captured ? ( + ↵ save · ⎋ cancel + ) : ( + ⎋ cancel + )} +
+ ); +} + +type RecordingMode = { type: "add" } | { type: "edit"; key: string } | null; + +interface BindingChipProps { + shortcutId: ConfigurableShortcutId; + combo: string; + commandLabel: string; + canRemove: boolean; + canAddMore: boolean; + isAtDefault: boolean; + isRecording: boolean; + onStartRecording: (mode: RecordingMode) => void; + onRemove: () => void; + onReset: () => void; + onSave: (combo: string) => void; + onCancelRecording: () => void; +} + +function BindingChip({ + shortcutId, + combo, + commandLabel, + canRemove, + canAddMore, + isAtDefault, + isRecording, + onStartRecording, + onRemove, + onReset, + onSave, + onCancelRecording, +}: BindingChipProps) { + const parts = formatHotkeyParts(combo); + + if (isRecording) { + return ( + + ); + } + + return ( + + + + + + + {canAddMore ? ( + onStartRecording({ type: "add" })}> + Add another binding + + ) : ( + + + + Add another binding + + + + )} + {isAtDefault ? ( + + + + Reset to default + + + + ) : ( + + Reset to default + + )} + {canRemove ? ( + Remove binding + ) : ( + + + + Remove binding + + + + )} + + + ); +} + +interface ShortcutRecorderProps { + id: ConfigurableShortcutId; + onRecordingChange?: (recording: boolean) => void; +} + +export function ShortcutRecorder({ + id, + onRecordingChange, +}: ShortcutRecorderProps) { + const [recordingMode, setRecordingMode] = useState(null); + const customs = useKeybindingsStore((s) => s.customKeybindings[id] ?? []); + const removeKeybinding = useKeybindingsStore((s) => s.removeKeybinding); + const resetShortcut = useKeybindingsStore((s) => s.resetShortcut); + const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); + const updateKeybinding = useKeybindingsStore((s) => s.updateKeybinding); + const hasCustom = customs.length > 0; + + const shortcutEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === id); + + const defaultBindings = splitBindings(DEFAULT_KEYBINDINGS[id]); + const effectiveBindings = hasCustom ? customs : defaultBindings; + const canAddMore = customs.length < MAX_CUSTOM_BINDINGS; + + const startRecording = useCallback( + (mode: RecordingMode) => { + setRecordingMode(mode); + onRecordingChange?.(true); + }, + [onRecordingChange], + ); + + const stopRecording = useCallback(() => { + setRecordingMode(null); + onRecordingChange?.(false); + }, [onRecordingChange]); + + const handleRemoveDefault = useCallback( + (key: string) => { + const remaining = defaultBindings.filter((k) => k !== key); + resetShortcut(id); + for (const k of remaining) { + addKeybinding(id, k); + } + }, + [id, defaultBindings, resetShortcut, addKeybinding], + ); + + const handleSave = useCallback( + (combo: string) => { + if (recordingMode?.type === "edit") { + updateKeybinding(id, recordingMode.key, combo); + } else { + addKeybinding(id, combo); + } + stopRecording(); + }, + [recordingMode, id, updateKeybinding, addKeybinding, stopRecording], + ); + + if (!shortcutEntry) return null; + + const commandLabel = shortcutEntry.description; + const isAtDefault = !hasCustom; + const isAddingNew = recordingMode !== null && recordingMode.type === "add"; + + return ( +
+ + {effectiveBindings.map((key, i) => { + const isEditingThis = + recordingMode?.type === "edit" && recordingMode.key === key; + return ( + + {i > 0 && ( + + or + + )} + 1} + canAddMore={canAddMore} + isAtDefault={isAtDefault} + isRecording={isEditingThis} + onStartRecording={startRecording} + onRemove={ + hasCustom + ? () => removeKeybinding(id, key) + : () => handleRemoveDefault(key) + } + onReset={() => resetShortcut(id)} + onSave={handleSave} + onCancelRecording={stopRecording} + /> + + ); + })} + + {/* Inline recorder for adding a new binding */} + {isAddingNew && ( + + {effectiveBindings.length > 0 && ( + + or + + )} + + + )} + +
+ ); +} diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 68c994d35a..5a772cb658 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -329,19 +329,24 @@ export function getShortcutsByCategory(): Record< return grouped; } -/** - * Convert a DOM KeyboardEvent to the normalised combo string used by the - * keybindings store (e.g. "mod+shift+v"). Returns null for bare modifier presses. - */ +function buildModifierParts(e: KeyboardEvent): string[] { + const parts: string[] = []; + if (e.metaKey) parts.push("mod"); + // On Mac, Ctrl is a distinct key (⌃), not the same as Cmd (mod). On Windows/Linux, + // Ctrl maps to mod since there is no meta key. + if (e.ctrlKey) parts.push(isMac ? "ctrl" : "mod"); + if (e.shiftKey) parts.push("shift"); + if (e.altKey) parts.push("alt"); + // Deduplicate — on Windows, metaKey+ctrlKey would both produce "mod" + return [...new Set(parts)]; +} + export function eventToCombo(e: KeyboardEvent): string | null { const bare = ["Meta", "Control", "Shift", "Alt"]; if (bare.includes(e.key)) return null; if (!(e.metaKey || e.ctrlKey || e.altKey)) return null; - const parts: string[] = []; - if (e.metaKey || e.ctrlKey) parts.push("mod"); - if (e.shiftKey) parts.push("shift"); - if (e.altKey) parts.push("alt"); + const parts = buildModifierParts(e); // Normalize "ArrowUp" → "up", "ArrowDown" → "down", etc. to match stored bindings. parts.push(e.key.toLowerCase().replace(/^arrow/, "")); return parts.join("+"); @@ -356,14 +361,40 @@ export function tiptapEventToCombo(e: KeyboardEvent): string | null { if (bare.includes(e.key)) return null; if (!(e.metaKey || e.ctrlKey || e.altKey || e.shiftKey)) return null; - const parts: string[] = []; - if (e.metaKey || e.ctrlKey) parts.push("mod"); - if (e.shiftKey) parts.push("shift"); - if (e.altKey) parts.push("alt"); + const parts = buildModifierParts(e); parts.push(e.key.toLowerCase().replace(/^arrow/, "")); return parts.join("+"); } +/** + * Like eventToCombo but also returns partial combos for bare modifier presses. + * Used in the inline shortcut recorder to show live combo feedback. + */ +export function recordingEventToCombo( + e: KeyboardEvent, +): { combo: string; isPartial: boolean } | null { + const bare = ["Meta", "Control", "Shift", "Alt"]; + + const parts = buildModifierParts(e); + if (parts.length === 0) return null; + + if (bare.includes(e.key)) { + // Only a modifier was pressed — partial combo + return { combo: parts.join("+"), isPartial: true }; + } + + if (!e.metaKey && !e.ctrlKey && !e.altKey) { + // Shift-only: allow non-printable keys (arrows, F-keys) since "shift+up" is a + // valid binding (prompt history default). Block printable keys — shift+letter + // would conflict with normal typing. + if (e.key.length === 1 || ["Enter", "Escape", "Tab"].includes(e.key)) + return null; + } + + parts.push(e.key.toLowerCase().replace(/^arrow/, "")); + return { combo: parts.join("+"), isPartial: false }; +} + function formatKey(key: string): string { const k = key.trim().toLowerCase(); if (k === "mod") return isMac ? "⌘" : "Ctrl"; diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index fa3c932063..9ab61a5123 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,13 +1,4 @@ import { - eventToCombo, - formatHotkey, - tiptapEventToCombo, -} from "@posthog/ui/features/command/keyboard-shortcuts"; -import { - splitBindings, - useKeybindingsStore, -} from "@posthog/ui/shell/keybindingsStore"; -import { contentToXml, type FileAttachment, isContentEmpty, @@ -26,9 +17,18 @@ import { isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; +import { + eventToCombo, + formatHotkey, + tiptapEventToCombo, +} from "@posthog/ui/features/command/keyboard-shortcuts"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { toast } from "@posthog/ui/primitives/toast"; +import { + splitBindings, + useKeybindingsStore, +} from "@posthog/ui/shell/keybindingsStore"; import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; @@ -613,7 +613,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if (enableBashMode && isBashModeText(text)) { // Bash mode requires immediate execution, can't be queued. - // Intentionally bypasses onBeforeSubmit — bash commands run inline and + // Intentionally bypasses onBeforeSubmit — bash commands run inline and // cannot be deferred the way normal prompts can. if (isLoading) { toast.error("Cannot run shell commands while agent is generating"); diff --git a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx deleted file mode 100644 index 99069cb47b..0000000000 --- a/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx +++ /dev/null @@ -1,476 +0,0 @@ -import { Keycap } from "./Keycap"; -import { ShortcutRecorder } from "./ShortcutRecorder"; -import { Tooltip } from "./Tooltip"; -import { useShortcut } from "./hooks/useShortcut"; -import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; -import { useCallback, useMemo, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { - CATEGORY_LABELS, - type ConfigurableShortcutId, - formatHotkeyParts, - getShortcutsByCategory, - type KeyboardShortcut, - type ShortcutCategory, -} from "../features/command/keyboard-shortcuts"; -import { - resolveKey, - splitBindings, - useKeybindingsStore, -} from "../shell/keybindingsStore"; - -interface KeyboardShortcutsSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -interface ComboSearch { - combo: string; - isPartial: boolean; -} - -const CATEGORY_ORDER: ShortcutCategory[] = [ - "general", - "navigation", - "panels", - "editor", -]; - -function eventToSearchCombo(e: KeyboardEvent): ComboSearch | null { - const isModifierKey = ["Meta", "Control", "Shift", "Alt"].includes(e.key); - const hasModifier = e.ctrlKey || e.metaKey || e.altKey || e.shiftKey; - if (!hasModifier) return null; - - const parts: string[] = []; - if (e.metaKey || e.ctrlKey) parts.push("mod"); - if (e.shiftKey) parts.push("shift"); - if (e.altKey) parts.push("alt"); - - if (!isModifierKey) { - parts.push(e.key.toLowerCase().replace(/^arrow/, "")); - return { combo: parts.join("+"), isPartial: false }; - } - return parts.length > 0 ? { combo: parts.join("+"), isPartial: true } : null; -} - -function shortcutMatchesText( - shortcut: KeyboardShortcut, - query: string, -): boolean { - const q = query.toLowerCase(); - return ( - shortcut.description.toLowerCase().includes(q) || - (shortcut.context?.toLowerCase().includes(q) ?? false) - ); -} - -function shortcutMatchesCombo( - shortcut: KeyboardShortcut, - customKeybindings: Partial>, - { combo, isPartial }: ComboSearch, -): boolean { - let bindingStr: string; - if (shortcut.configurable) { - bindingStr = resolveKey( - customKeybindings, - shortcut.id as ConfigurableShortcutId, - ); - } else { - bindingStr = shortcut.keys; - if (shortcut.alternateKeys) bindingStr += `,${shortcut.alternateKeys}`; - } - return splitBindings(bindingStr).some((b) => - isPartial ? b === combo || b.startsWith(`${combo}+`) : b === combo, - ); -} - -interface ShortcutsSearchBarProps { - searchText: string; - comboSearch: ComboSearch | null; - onTextChange: (text: string) => void; - onComboChange: (combo: ComboSearch | null) => void; - onClear: () => void; -} - -function ShortcutsSearchBar({ - searchText, - comboSearch, - onTextChange, - onComboChange, - onClear, -}: ShortcutsSearchBarProps) { - const isComboMode = comboSearch !== null; - const parts = comboSearch ? formatHotkeyParts(comboSearch.combo) : []; - const hasContent = isComboMode || searchText.length > 0; - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const inComboMode = comboSearch !== null; - - // Backspace in combo mode clears the combo search. - if (e.key === "Backspace" && inComboMode) { - e.preventDefault(); - onComboChange(null); - return; - } - - const result = eventToSearchCombo(e.nativeEvent); - if (result) { - e.preventDefault(); - if (!result.isPartial) onTextChange(""); - onComboChange(result); - return; - } - - // Typing a regular character in combo mode exits combo mode and starts text search. - if (inComboMode && e.key.length === 1) { - e.preventDefault(); - onComboChange(null); - onTextChange(e.key); - } - }, - [comboSearch, onComboChange, onTextChange], - ); - - const handleKeyUp = useCallback( - (e: React.KeyboardEvent) => { - if (!comboSearch?.isPartial) return; - onComboChange(eventToSearchCombo(e.nativeEvent)); - }, - [comboSearch, onComboChange], - ); - - return ( -
-
- -
- - { - if (!isComboMode) onTextChange(e.target.value); - }} - onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} - placeholder={isComboMode ? "" : "Search by name or press a key combo…"} - type="text" - value={isComboMode ? "" : searchText} - /> - - {isComboMode && ( -
- {parts.map((part) => ( - - ))} - {comboSearch.isPartial && ( - … - )} -
- )} - - {hasContent && ( - - )} -
- ); -} - -export function KeyboardShortcutsSheet({ - open, - onOpenChange, -}: KeyboardShortcutsSheetProps) { - const [searchText, setSearchText] = useState(""); - const [comboSearch, setComboSearch] = useState(null); - - const clearSearch = useCallback(() => { - setSearchText(""); - setComboSearch(null); - }, []); - - const handleOpenChange = useCallback( - (newOpen: boolean) => { - if (!newOpen) { - setSearchText(""); - setComboSearch(null); - } - onOpenChange(newOpen); - }, - [onOpenChange], - ); - - useHotkeys("escape", () => handleOpenChange(false), { - enabled: open, - enableOnContentEditable: true, - enableOnFormTags: true, - preventDefault: true, - }); - - return ( - - e.preventDefault()} - className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden" - > - - - - - - - - - - {/* Bottom padding so list content doesn't sit behind the sticky footer */} - - - - - - - ); -} - -function ResetAllFooter() { - const hasCustomBindings = useKeybindingsStore((s) => - Object.keys(s.customKeybindings).some( - (k) => - (s.customKeybindings[k as ConfigurableShortcutId]?.length ?? 0) > 0, - ), - ); - const resetAll = useKeybindingsStore((s) => s.resetAll); - - if (!hasCustomBindings) return null; - - return ( - - - - ); -} - -function ShortcutsHeader() { - const shortcutsKey = useShortcut("shortcuts"); - const triggerParts = formatHotkeyParts(shortcutsKey); - - return ( - - - - Keyboard Combos - - - {triggerParts.map((part) => ( - - ))} - - - - Your cheat codes for shipping faster - - - ); -} - -interface KeyboardShortcutsListProps { - searchText?: string; - comboSearch?: ComboSearch | null; -} - -export function KeyboardShortcutsList({ - searchText = "", - comboSearch = null, -}: KeyboardShortcutsListProps = {}) { - const customKeybindings = useKeybindingsStore((s) => s.customKeybindings); - - const filteredByCategory = useMemo(() => { - const base = getShortcutsByCategory(); - if (!searchText && !comboSearch) return base; - - const result: Record = { - general: [], - navigation: [], - panels: [], - editor: [], - }; - for (const category of CATEGORY_ORDER) { - result[category] = base[category].filter((shortcut) => - comboSearch - ? shortcutMatchesCombo(shortcut, customKeybindings, comboSearch) - : shortcutMatchesText(shortcut, searchText), - ); - } - return result; - }, [searchText, comboSearch, customKeybindings]); - - const hasResults = CATEGORY_ORDER.some( - (c) => filteredByCategory[c].length > 0, - ); - - if (!hasResults && (searchText || comboSearch)) { - return ( - - - No shortcuts found - - - ); - } - - return ( - - {CATEGORY_ORDER.map((category) => { - const shortcuts = filteredByCategory[category]; - if (shortcuts.length === 0) return null; - - const uniqueShortcuts = shortcuts.reduce( - (acc, shortcut) => { - const existing = acc.find( - (s) => s.description === shortcut.description, - ); - if (!existing) { - acc.push(shortcut); - } - return acc; - }, - [] as typeof shortcuts, - ); - - return ( - - - {CATEGORY_LABELS[category]} - - - {uniqueShortcuts.map((shortcut) => ( - - - {shortcut.description} - {shortcut.context && ( - - {shortcut.context} - - )} - -
- {shortcut.configurable ? ( - - ) : ( - - )} -
-
- ))} -
-
- ); - })} -
- ); -} - -function SingleShortcutKeys({ keys }: { keys: string }) { - const parts = formatHotkeyParts(keys); - return ( - - {parts.map((part) => ( - - ))} - - ); -} - -function ShortcutKeys({ - keys, - alternateKeys, -}: { - keys: string; - alternateKeys?: string; -}) { - const inner = alternateKeys ? ( - - - - or - - - - ) : ( - - ); - - return ( - -
- {inner} -
-
- ); -} diff --git a/packages/ui/src/primitives/Keycap.tsx b/packages/ui/src/primitives/Keycap.tsx index e94f419197..fb415be164 100644 --- a/packages/ui/src/primitives/Keycap.tsx +++ b/packages/ui/src/primitives/Keycap.tsx @@ -1,12 +1,9 @@ -import { useState } from "react"; - interface KeycapProps { label: string; size?: "sm" | "md"; } export function Keycap({ label, size = "md" }: KeycapProps) { - const [pressed, setPressed] = useState(false); const isSmall = size === "sm"; const minW = isSmall ? "22px" : "28px"; const h = isSmall ? "22px" : "28px"; @@ -14,27 +11,17 @@ export function Keycap({ label, size = "md" }: KeycapProps) { const shadowSize = isSmall ? "2px" : "3px"; return ( - // biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation setPressed(true)} - onMouseUp={() => setPressed(false)} - onMouseLeave={() => setPressed(false)} style={{ minWidth: minW, height: h, fontSize, fontFamily: "system-ui, -apple-system, sans-serif", lineHeight: 1, - borderBottomWidth: pressed ? "1px" : shadowSize, + borderBottomWidth: shadowSize, borderBottomColor: "var(--gray-7)", - transform: pressed - ? `translateY(${isSmall ? "1px" : "2px"})` - : "translateY(0)", - transition: - "transform 80ms ease-out, border-bottom-width 80ms ease-out", }} - className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)" + className="box-border inline-flex select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)" > {label} diff --git a/packages/ui/src/primitives/ShortcutRecorder.tsx b/packages/ui/src/primitives/ShortcutRecorder.tsx deleted file mode 100644 index e54237f0f2..0000000000 --- a/packages/ui/src/primitives/ShortcutRecorder.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { Keycap } from "./Keycap"; -import { Tooltip } from "./Tooltip"; -import { ContextMenu, Flex, Text } from "@radix-ui/themes"; -import { - type ConfigurableShortcutId, - DEFAULT_KEYBINDINGS, - eventToCombo, - formatHotkeyParts, - KEYBOARD_SHORTCUTS, -} from "../features/command/keyboard-shortcuts"; -import { - findConflict, - MAX_CUSTOM_BINDINGS, - splitBindings, - useKeybindingsStore, -} from "../shell/keybindingsStore"; -import { useCallback, useEffect, useState } from "react"; - -function formatComboLabel(combo: string): string { - return formatHotkeyParts(combo).join("+"); -} - -interface RecordingModalProps { - commandLabel: string; - /** null = adding a new binding, string = the binding key being edited */ - editingKey: string | null; - shortcutId: ConfigurableShortcutId; - onClose: () => void; -} - -export function ShortcutRecordingModal({ - commandLabel, - editingKey, - shortcutId, - onClose, -}: RecordingModalProps) { - const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); - const updateKeybinding = useKeybindingsStore((s) => s.updateKeybinding); - const [captured, setCaptured] = useState(null); - const [conflict, setConflict] = useState(null); - - // Capture at the window level — no focus required on the input element. - useEffect(() => { - const handler = (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (e.key === "Escape") { - onClose(); - return; - } - - if (e.key === "Enter") { - if (captured && !conflict) { - if (editingKey) { - updateKeybinding(shortcutId, editingKey, captured); - } else { - addKeybinding(shortcutId, captured); - } - onClose(); - } - return; - } - - const combo = eventToCombo(e); - if (!combo) return; - - const result = findConflict(combo, shortcutId); - if (result.description) { - setConflict(result.description); - setCaptured(combo); - } else { - setConflict(null); - setCaptured(combo); - } - }; - - window.addEventListener("keydown", handler, { capture: true }); - return () => - window.removeEventListener("keydown", handler, { capture: true }); - }, [ - captured, - conflict, - editingKey, - shortcutId, - addKeybinding, - updateKeybinding, - onClose, - ]); - - const isAdding = editingKey === null; - const title = isAdding - ? `Add new binding for "${commandLabel}"` - : `Edit binding for "${commandLabel}"`; - - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss pattern — not an interactive widget -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
-
- - {title} - -
- -
- {/* Visual input — display only; actual capture happens via window listener */} -
- {captured ? ( - formatComboLabel(captured) - ) : ( - - Press a key combination... - - )} -
- {conflict ? ( - - Conflicts with "{conflict}" — press a different - combination - - ) : captured ? ( - - Press Enter to confirm, Escape to cancel - - ) : ( - - Press Escape to cancel - - )} -
-
-
- ); -} - -type RecordingMode = { type: "add" } | { type: "edit"; key: string } | null; - -interface BindingChipProps { - combo: string; - commandLabel: string; - canRemove: boolean; - canAddMore: boolean; - isAtDefault: boolean; - onStartRecording: (mode: RecordingMode) => void; - onRemove: () => void; - onReset: () => void; -} - -function BindingChip({ - combo, - commandLabel, - canRemove, - canAddMore, - isAtDefault, - onStartRecording, - onRemove, - onReset, -}: BindingChipProps) { - const parts = formatHotkeyParts(combo); - - return ( - - - - - - - onStartRecording({ type: "edit", key: combo })} - > - Edit binding - - {canAddMore ? ( - onStartRecording({ type: "add" })}> - Add another binding - - ) : ( - - - - Add another binding - - - - )} - {isAtDefault ? ( - - - - Reset to default - - - - ) : ( - - Reset to default - - )} - {canRemove ? ( - - Remove binding - - ) : ( - - - - Remove binding - - - - )} - - - ); -} - -interface ShortcutRecorderProps { - id: ConfigurableShortcutId; - onRecordingChange?: (recording: boolean) => void; -} - -export function ShortcutRecorder({ - id, - onRecordingChange, -}: ShortcutRecorderProps) { - const [recordingMode, setRecordingMode] = useState(null); - const customs = useKeybindingsStore((s) => s.customKeybindings[id] ?? []); - const removeKeybinding = useKeybindingsStore((s) => s.removeKeybinding); - const resetShortcut = useKeybindingsStore((s) => s.resetShortcut); - const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); - const hasCustom = customs.length > 0; - - const shortcutEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === id); - - // Defaults are split into individual chips; customs replace them entirely. - // canAddMore counts custom bindings only — defaults don't consume the budget. - const defaultBindings = splitBindings(DEFAULT_KEYBINDINGS[id]); - const effectiveBindings = hasCustom ? customs : defaultBindings; - const canAddMore = customs.length < MAX_CUSTOM_BINDINGS; - - const startRecording = useCallback( - (mode: RecordingMode) => { - setRecordingMode(mode); - onRecordingChange?.(true); - }, - [onRecordingChange], - ); - - const stopRecording = useCallback(() => { - setRecordingMode(null); - onRecordingChange?.(false); - }, [onRecordingChange]); - - // Removing a default binding: store the remaining defaults as custom bindings. - const handleRemoveDefault = useCallback( - (key: string) => { - const remaining = defaultBindings.filter((k) => k !== key); - resetShortcut(id); - for (const k of remaining) { - addKeybinding(id, k); - } - }, - [id, defaultBindings, resetShortcut, addKeybinding], - ); - - if (!shortcutEntry) return null; - - const commandLabel = shortcutEntry.description; - const isAtDefault = !hasCustom; - - return ( - <> - {recordingMode !== null && ( - - )} - -
- - {effectiveBindings.map((key, i) => ( - - {i > 0 && ( - - or - - )} - 1} - canAddMore={canAddMore} - isAtDefault={isAtDefault} - onStartRecording={startRecording} - onRemove={ - hasCustom - ? () => removeKeybinding(id, key) - : () => handleRemoveDefault(key) - } - onReset={() => resetShortcut(id)} - /> - - ))} - -
- - ); -} From 9ced09b43623e95344403b21976932f3c891ec29 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 30 Jun 2026 16:34:01 +0100 Subject: [PATCH 17/23] fix(shortcuts): remove double-wrapped Kbd in DropdownMenuShortcut --- .../src/features/task-detail/components/ExternalAppsOpener.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx index e87b321df7..c2902394a3 100644 --- a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx +++ b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx @@ -1,4 +1,3 @@ -import { useShortcut } from "../../../primitives/hooks/useShortcut"; import { CodeIcon, CopyIcon } from "@phosphor-icons/react"; import { Button, @@ -13,6 +12,7 @@ import { import { ChevronDown } from "lucide-react"; import { useCallback } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { useShortcut } from "../../../primitives/hooks/useShortcut"; import { formatHotkeyParts } from "../../command/keyboard-shortcuts"; import { useExternalAppAction } from "../../external-apps/useExternalAppAction"; import { useExternalApps } from "../../external-apps/useExternalApps"; From 1e183f56d165b186510b5e25329c59b2aed741fb Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Wed, 1 Jul 2026 01:55:34 +0100 Subject: [PATCH 18/23] =?UTF-8?q?fix(shortcuts):=20recorder=20UX=20polish?= =?UTF-8?q?=20=E2=80=94=20shift=20guard,=20wider=20input,=20pencil=20overl?= =?UTF-8?q?ay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/command/ShortcutRecorder.tsx | 138 ++++++++---------- .../features/command/keyboard-shortcuts.ts | 15 +- 2 files changed, 62 insertions(+), 91 deletions(-) diff --git a/packages/ui/src/features/command/ShortcutRecorder.tsx b/packages/ui/src/features/command/ShortcutRecorder.tsx index d9fd9d5910..6e8eef4f1a 100644 --- a/packages/ui/src/features/command/ShortcutRecorder.tsx +++ b/packages/ui/src/features/command/ShortcutRecorder.tsx @@ -90,7 +90,6 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { }; }, [captured, conflict, shortcutId, onSave, onCancel]); - // Click outside to cancel useEffect(() => { const handler = (e: MouseEvent) => { if ( @@ -108,37 +107,42 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { const displayParts = displayCombo ? formatHotkeyParts(displayCombo) : null; return ( -
+
{displayParts ? ( - - {displayParts.map((part) => ( - - ))} - {partial && ( - - … + <> + + {displayParts.map((part) => ( + + ))} + {partial && ( + + … + + )} + + {!partial && ( + + {conflict ? "⎋ cancel" : "↵ save · ⎋ cancel"} )} - + ) : ( - Press keys… + + Press keys… · ⎋ cancel + )}
- {conflict ? ( - + {conflict && ( + Conflicts with "{conflict}" - ) : captured ? ( - ↵ save · ⎋ cancel - ) : ( - ⎋ cancel )}
); @@ -147,59 +151,47 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { type RecordingMode = { type: "add" } | { type: "edit"; key: string } | null; interface BindingChipProps { - shortcutId: ConfigurableShortcutId; combo: string; commandLabel: string; canRemove: boolean; canAddMore: boolean; isAtDefault: boolean; - isRecording: boolean; onStartRecording: (mode: RecordingMode) => void; onRemove: () => void; onReset: () => void; - onSave: (combo: string) => void; - onCancelRecording: () => void; } function BindingChip({ - shortcutId, combo, commandLabel, canRemove, canAddMore, isAtDefault, - isRecording, onStartRecording, onRemove, onReset, - onSave, - onCancelRecording, }: BindingChipProps) { const parts = formatHotkeyParts(combo); - if (isRecording) { - return ( - - ); - } - return ( + {/* + * Pencil icon is absolutely positioned so it has zero layout impact — + * keeping the chip width equal to its Keycap labels only. This ensures + * the "or" separator between two bindings stays evenly spaced, and + * non-configurable shortcuts align flush with configurable ones. + */} @@ -317,58 +309,44 @@ export function ShortcutRecorder({ const commandLabel = shortcutEntry.description; const isAtDefault = !hasCustom; - const isAddingNew = recordingMode !== null && recordingMode.type === "add"; + + // While recording (edit or add), show only the recorder — hide all binding chips + if (recordingMode !== null) { + return ( + + ); + } return (
- {effectiveBindings.map((key, i) => { - const isEditingThis = - recordingMode?.type === "edit" && recordingMode.key === key; - return ( - - {i > 0 && ( - - or - - )} - 1} - canAddMore={canAddMore} - isAtDefault={isAtDefault} - isRecording={isEditingThis} - onStartRecording={startRecording} - onRemove={ - hasCustom - ? () => removeKeybinding(id, key) - : () => handleRemoveDefault(key) - } - onReset={() => resetShortcut(id)} - onSave={handleSave} - onCancelRecording={stopRecording} - /> - - ); - })} - - {/* Inline recorder for adding a new binding */} - {isAddingNew && ( - - {effectiveBindings.length > 0 && ( + {effectiveBindings.map((key, i) => ( + + {i > 0 && ( or )} - 1} + canAddMore={canAddMore} + isAtDefault={isAtDefault} + onStartRecording={startRecording} + onRemove={ + hasCustom + ? () => removeKeybinding(id, key) + : () => handleRemoveDefault(key) + } + onReset={() => resetShortcut(id)} /> - )} + ))}
); diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 5a772cb658..6bdb9a3a71 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -369,28 +369,21 @@ export function tiptapEventToCombo(e: KeyboardEvent): string | null { /** * Like eventToCombo but also returns partial combos for bare modifier presses. * Used in the inline shortcut recorder to show live combo feedback. + * Requires at least one of Cmd/Ctrl/Alt — shift alone is not a valid leading modifier. */ export function recordingEventToCombo( e: KeyboardEvent, ): { combo: string; isPartial: boolean } | null { - const bare = ["Meta", "Control", "Shift", "Alt"]; + if (!e.metaKey && !e.ctrlKey && !e.altKey) return null; + const bare = ["Meta", "Control", "Shift", "Alt"]; const parts = buildModifierParts(e); - if (parts.length === 0) return null; if (bare.includes(e.key)) { - // Only a modifier was pressed — partial combo + // Only modifier keys pressed so far — partial combo return { combo: parts.join("+"), isPartial: true }; } - if (!e.metaKey && !e.ctrlKey && !e.altKey) { - // Shift-only: allow non-printable keys (arrows, F-keys) since "shift+up" is a - // valid binding (prompt history default). Block printable keys — shift+letter - // would conflict with normal typing. - if (e.key.length === 1 || ["Enter", "Escape", "Tab"].includes(e.key)) - return null; - } - parts.push(e.key.toLowerCase().replace(/^arrow/, "")); return { combo: parts.join("+"), isPartial: false }; } From 9bc608550d2a31efc16d22418c7d1f9476ed863e Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Wed, 1 Jul 2026 02:28:28 +0100 Subject: [PATCH 19/23] fix(shortcuts): placeholder copy, Windows key guard, restore pencil beside chip --- .../command/KeyboardShortcutsSheet.tsx | 7 +- .../src/features/command/ShortcutRecorder.tsx | 73 +++++++++---------- .../features/command/keyboard-shortcuts.ts | 4 + 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index d0089ea07f..cfbbd3b076 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -500,7 +500,12 @@ function ShortcutKeys({ side="left" delayDuration={400} > -
+ {/* + * pr-[16px] matches the space a configurable BindingChip reserves for its + * pencil icon (2px gap + 12px icon + 2px button right-padding = 16px), + * keeping all shortcut keys right-aligned in the same column. + */} +
{inner}
diff --git a/packages/ui/src/features/command/ShortcutRecorder.tsx b/packages/ui/src/features/command/ShortcutRecorder.tsx index 6e8eef4f1a..c36d8a53ea 100644 --- a/packages/ui/src/features/command/ShortcutRecorder.tsx +++ b/packages/ui/src/features/command/ShortcutRecorder.tsx @@ -107,41 +107,42 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { const displayParts = displayCombo ? formatHotkeyParts(displayCombo) : null; return ( -
+
{displayParts ? ( - <> - - {displayParts.map((part) => ( - - ))} - {partial && ( - - … - - )} - - {!partial && ( - - {conflict ? "⎋ cancel" : "↵ save · ⎋ cancel"} + + {displayParts.map((part) => ( + + ))} + {partial && ( + + … )} - + ) : ( - Press keys… · ⎋ cancel + Press a key combination... )}
- {conflict && ( - - Conflicts with "{conflict}" + {conflict ? ( + + Conflicts with "{conflict}" — press a different combination + + ) : captured ? ( + + Press Enter to confirm, Escape to cancel + + ) : ( + + Press Escape to cancel )}
@@ -176,25 +177,21 @@ function BindingChip({ return ( - {/* - * Pencil icon is absolutely positioned so it has zero layout impact — - * keeping the chip width equal to its Keycap labels only. This ensures - * the "or" separator between two bindings stays evenly spaced, and - * non-configurable shortcuts align flush with configurable ones. - */} - + - +
diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 6bdb9a3a71..39f0a48a14 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -375,6 +375,10 @@ export function recordingEventToCombo( e: KeyboardEvent, ): { combo: string; isPartial: boolean } | null { if (!e.metaKey && !e.ctrlKey && !e.altKey) return null; + // On Windows, the Windows key fires e.metaKey without e.ctrlKey. It maps to the + // same "mod" token as Ctrl (both display as "Ctrl"), which is confusing. OS also + // intercepts most Win+letter combos before they reach the app, so block it here. + if (!isMac && e.metaKey && !e.ctrlKey) return null; const bare = ["Meta", "Control", "Shift", "Alt"]; const parts = buildModifierParts(e); From 9e1d8c3322c723516dc275fa8db89342c7b6d266 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Wed, 1 Jul 2026 02:55:47 +0100 Subject: [PATCH 20/23] =?UTF-8?q?fix(shortcuts):=20UX=20polish=20=E2=80=94?= =?UTF-8?q?=20full-width=20recorder,=20balanced=20or-spacing,=20pencil=20c?= =?UTF-8?q?lick,=20conflict=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/KeyboardShortcutsSheet.tsx | 81 ++++++++++++------- .../src/features/command/ShortcutRecorder.tsx | 22 +++-- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index cfbbd3b076..568070cd6c 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -365,6 +365,9 @@ export function KeyboardShortcutsList({ comboSearch = null, }: KeyboardShortcutsListProps = {}) { const customKeybindings = useKeybindingsStore((s) => s.customKeybindings); + const [recordingId, setRecordingId] = useState( + null, + ); const filteredByCategory = useMemo(() => { const base = getShortcutsByCategory(); @@ -425,37 +428,53 @@ export function KeyboardShortcutsList({ {CATEGORY_LABELS[category]} - {uniqueShortcuts.map((shortcut) => ( - - - {shortcut.description} - {shortcut.context && ( - - {shortcut.context} - - )} + {uniqueShortcuts.map((shortcut) => { + const isRecording = + shortcut.configurable && + recordingId === (shortcut.id as ConfigurableShortcutId); + return ( + + + {shortcut.description} + {shortcut.context && ( + + {shortcut.context} + + )} + +
+ {shortcut.configurable ? ( + + setRecordingId( + r + ? (shortcut.id as ConfigurableShortcutId) + : null, + ) + } + /> + ) : ( + + )} +
-
- {shortcut.configurable ? ( - - ) : ( - - )} -
-
- ))} + ); + })}
); @@ -505,7 +524,7 @@ function ShortcutKeys({ * pencil icon (2px gap + 12px icon + 2px button right-padding = 16px), * keeping all shortcut keys right-aligned in the same column. */} -
+
{inner}
diff --git a/packages/ui/src/features/command/ShortcutRecorder.tsx b/packages/ui/src/features/command/ShortcutRecorder.tsx index c36d8a53ea..a6adedf37f 100644 --- a/packages/ui/src/features/command/ShortcutRecorder.tsx +++ b/packages/ui/src/features/command/ShortcutRecorder.tsx @@ -107,16 +107,16 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { const displayParts = displayCombo ? formatHotkeyParts(displayCombo) : null; return ( -
+
{displayParts ? ( - + {displayParts.map((part) => ( ))} @@ -127,14 +127,14 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { )} ) : ( - + Press a key combination... )}
{conflict ? ( - Conflicts with "{conflict}" — press a different combination + Conflicts with "{conflict}". Press a different combination. ) : captured ? ( @@ -188,9 +188,15 @@ function BindingChip({ ))} - +
@@ -324,7 +330,7 @@ export function ShortcutRecorder({ {effectiveBindings.map((key, i) => ( {i > 0 && ( - + or )} From 4788fea2138c97d15a9395dd870a0f8cb4de9d42 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Wed, 1 Jul 2026 03:44:13 +0100 Subject: [PATCH 21/23] =?UTF-8?q?fix(shortcuts):=20recorder=20UX=20polish?= =?UTF-8?q?=20=E2=80=94=20hint=20text,=20or=20sizing,=20chip=20clickabilit?= =?UTF-8?q?y,=20ellipsis=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/KeyboardShortcutsSheet.tsx | 80 ++++++++++--------- .../src/features/command/ShortcutRecorder.tsx | 44 ++++------ 2 files changed, 60 insertions(+), 64 deletions(-) diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index 568070cd6c..51f26534c0 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -183,7 +183,7 @@ function ShortcutsSearchBar({ ))} {comboSearch.isPartial && ( - … + … )}
)} @@ -433,46 +433,54 @@ export function KeyboardShortcutsList({ shortcut.configurable && recordingId === (shortcut.id as ConfigurableShortcutId); return ( - - {shortcut.description} - {shortcut.context && ( - - {shortcut.context} - - )} + + {shortcut.description} + {shortcut.context && ( + + {shortcut.context} + + )} + +
+ {shortcut.configurable ? ( + + setRecordingId( + r + ? (shortcut.id as ConfigurableShortcutId) + : null, + ) + } + /> + ) : ( + + )} +
-
- {shortcut.configurable ? ( - - setRecordingId( - r - ? (shortcut.id as ConfigurableShortcutId) - : null, - ) - } - /> - ) : ( - - )} -
-
+
); })} diff --git a/packages/ui/src/features/command/ShortcutRecorder.tsx b/packages/ui/src/features/command/ShortcutRecorder.tsx index a6adedf37f..d968868af8 100644 --- a/packages/ui/src/features/command/ShortcutRecorder.tsx +++ b/packages/ui/src/features/command/ShortcutRecorder.tsx @@ -121,9 +121,7 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) { ))} {partial && ( - - … - + … )} ) : ( @@ -134,15 +132,11 @@ function InlineRecorder({ shortcutId, onSave, onCancel }: InlineRecorderProps) {
{conflict ? ( - Conflicts with "{conflict}". Press a different combination. - - ) : captured ? ( - - Press Enter to confirm, Escape to cancel + Conflicts with "{conflict}". ) : ( - - Press Escape to cancel + + Press Enter to save · Escape to cancel )}
@@ -177,27 +171,21 @@ function BindingChip({ return ( -
- - -
+ +
@@ -330,7 +318,7 @@ export function ShortcutRecorder({ {effectiveBindings.map((key, i) => ( {i > 0 && ( - + or )} From 8ff1896a15c80a460969ffd788bcbd539adf23e9 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Sun, 12 Jul 2026 12:29:04 +0100 Subject: [PATCH 22/23] feat(sessions): port message keyboard nav + jump picker to ChatThread Re-targets #2361's Alt/Option+Up/Down message navigation and Cmd/Ctrl+J jump-to-message picker (with date filter) from the legacy ConversationView onto the new ChatThread view, so the feature survives once useNewChatThread is flipped on and ConversationView is removed. Adds the message-prev/message-next/message-jump shortcuts (new in #2361, not part of #2321) and wires navigation + the jump picker through ChatThread's own scroller engine (scrollToMessage by id) instead of the legacy VirtualizedList's index-based scrollToIndex. --- .../features/command/keyboard-shortcuts.ts | 33 ++ .../components/chat-thread/ChatThread.tsx | 170 +++++- .../chat-thread/MessageJumpPicker.tsx | 500 ++++++++++++++++++ 3 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 39f0a48a14..36158569fc 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -23,6 +23,9 @@ export const SHORTCUTS = { SPACE_DOWN: "mod+down", FIND_IN_CONVERSATION: "mod+f", FILE_PICKER: "mod+p", + MESSAGE_PREV: "alt+up", + MESSAGE_NEXT: "alt+down", + MESSAGE_JUMP: "mod+j", BLUR: "escape", SUBMIT_BLUR: "mod+enter", SWITCH_MESSAGING_MODE: "mod+s", @@ -202,6 +205,30 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ context: "Task detail", configurable: true, }, + { + id: "message-prev", + keys: SHORTCUTS.MESSAGE_PREV, + description: "Previous message", + category: "panels", + context: "Task detail", + configurable: true, + }, + { + id: "message-next", + keys: SHORTCUTS.MESSAGE_NEXT, + description: "Next message", + category: "panels", + context: "Task detail", + configurable: true, + }, + { + id: "message-jump", + keys: SHORTCUTS.MESSAGE_JUMP, + description: "Jump to message", + category: "panels", + context: "Task detail", + configurable: true, + }, { id: "paste-as-file", keys: SHORTCUTS.PASTE_AS_FILE, @@ -282,6 +309,9 @@ export const CONFIGURABLE_SHORTCUT_IDS = [ "copy-path", "toggle-focus", "file-picker", + "message-prev", + "message-next", + "message-jump", "paste-as-file", "prompt-history-prev", "prompt-history-next", @@ -308,6 +338,9 @@ export const DEFAULT_KEYBINDINGS: Record = { "copy-path": SHORTCUTS.COPY_PATH, "toggle-focus": SHORTCUTS.TOGGLE_FOCUS, "file-picker": SHORTCUTS.FILE_PICKER, + "message-prev": SHORTCUTS.MESSAGE_PREV, + "message-next": SHORTCUTS.MESSAGE_NEXT, + "message-jump": SHORTCUTS.MESSAGE_JUMP, "paste-as-file": SHORTCUTS.PASTE_AS_FILE, "prompt-history-prev": "shift+up", "prompt-history-next": "shift+down", diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 45fd378ee9..d07ddc85d9 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -28,6 +28,7 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/ import { ChatMarkdown } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter"; import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome"; +import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; import { ToolGroup, type ToolGroupItem, @@ -58,6 +59,7 @@ import { } from "@posthog/ui/features/sessions/useSessionTaskId"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; +import { useShortcut } from "@posthog/ui/primitives/hooks/useShortcut"; import { DIFF_WORKER_FACTORY, type DiffWorkerFactory, @@ -73,6 +75,7 @@ import { useRef, useState, } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import type { ConversationViewProps } from "../ConversationView"; @@ -189,10 +192,12 @@ function UserBubble({ content, timestamp, attachments = [], + keyboardFocused = false, }: { content: string; timestamp?: number; attachments?: UserMessageAttachment[]; + keyboardFocused?: boolean; }) { const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, @@ -252,7 +257,13 @@ function UserBubble({ }, [displayContent, isExpanded]); return ( - + {showHeaderChips && ( @@ -467,9 +478,11 @@ function StickyHeaderOverlay({ items }: { items: ConversationItem[] }) { const ThreadRow = memo(function ThreadRow({ item, renderItem, + keyboardFocused, }: { item: ThreadItem; renderItem: (item: ConversationItem) => ReactNode; + keyboardFocused?: boolean; }) { return ( ) : ( renderItem(item) @@ -493,28 +507,155 @@ const ThreadRow = memo(function ThreadRow({ ); }); +/** + * Keyboard message navigation (Alt/Option+Up/Down) and the Cmd/Ctrl+J jump picker. Rendered inside + * `ChatMessageScrollerProvider` so it can call `scrollToMessage` from the engine — the same primitive + * `StickyHeaderOverlay` uses to jump back to the anchored turn. + */ +function ThreadKeyboardNav({ + items, + jumpPickerOpen, + setJumpPickerOpen, + keyboardFocusedMessageId, + setKeyboardFocusedMessageId, + messageJumpKey, + previousMessageKey, + nextMessageKey, +}: { + items: ConversationItem[]; + jumpPickerOpen: boolean; + setJumpPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + keyboardFocusedMessageId: string | null; + setKeyboardFocusedMessageId: (id: string | null) => void; + messageJumpKey: string; + previousMessageKey: string; + nextMessageKey: string; +}) { + const { scrollToMessage } = useChatMessageScroller(); + + const userMessageIds = useMemo( + () => + items + .filter( + (item): item is Extract => + item.type === "user_message", + ) + .map((item) => item.id), + [items], + ); + + useHotkeys(messageJumpKey, () => setJumpPickerOpen((prev) => !prev), { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }); + + const handleNavigateMessage = useCallback( + (direction: -1 | 1) => { + if (userMessageIds.length === 0) return; + + const currentIndex = keyboardFocusedMessageId + ? userMessageIds.indexOf(keyboardFocusedMessageId) + : -1; + + const nextIndex = + currentIndex === -1 + ? direction > 0 + ? 0 + : userMessageIds.length - 1 + : Math.max( + 0, + Math.min(userMessageIds.length - 1, currentIndex + direction), + ); + + const nextId = userMessageIds[nextIndex]; + if (!nextId) return; + + setKeyboardFocusedMessageId(nextId); + scrollToMessage(nextId); + }, + [ + keyboardFocusedMessageId, + userMessageIds, + setKeyboardFocusedMessageId, + scrollToMessage, + ], + ); + + useHotkeys(previousMessageKey, () => handleNavigateMessage(-1), { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }); + + useHotkeys(nextMessageKey, () => handleNavigateMessage(1), { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }); + + useEffect(() => { + if ( + keyboardFocusedMessageId && + !userMessageIds.includes(keyboardFocusedMessageId) + ) { + setKeyboardFocusedMessageId(null); + } + }, [keyboardFocusedMessageId, userMessageIds, setKeyboardFocusedMessageId]); + + const handleJumpToMessage = useCallback( + (id: string) => { + setKeyboardFocusedMessageId(id); + scrollToMessage(id); + }, + [scrollToMessage, setKeyboardFocusedMessageId], + ); + + return ( + + ); +} + /** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */ function ThreadScrollBody({ items, rows, renderItem, footer, + keyboardFocusedMessageId, + onUserInteract, }: { items: ConversationItem[]; rows: ThreadItem[]; renderItem: (item: ConversationItem) => ReactNode; /** Status row (duration / context usage) pinned as the last item in the thread. */ footer?: ReactNode; + keyboardFocusedMessageId?: string | null; + /** Clears keyboard-focused message state on any pointer interaction with the thread. */ + onUserInteract?: () => void; }) { // `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread, // mirroring the legacy ConversationView container. return ( - + {rows.map((item) => ( - + ))} {footer && (
(() => groupToolRuns(items), [items]); + const [jumpPickerOpen, setJumpPickerOpen] = useState(false); + const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState< + string | null + >(null); + const messageJumpKey = useShortcut("message-jump"); + const previousMessageKey = useShortcut("message-prev"); + const nextMessageKey = useShortcut("message-next"); + const clearKeyboardFocus = useCallback(() => { + setKeyboardFocusedMessageId(null); + }, []); + const renderItem = useCallback( (item: ConversationItem) => { switch (item.type) { @@ -663,6 +815,8 @@ export function ChatThread({ items={items} rows={rows} renderItem={renderItem} + keyboardFocusedMessageId={keyboardFocusedMessageId} + onUserInteract={clearKeyboardFocus} footer={ } /> + diff --git a/packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx b/packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx new file mode 100644 index 0000000000..bfeac7940c --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx @@ -0,0 +1,500 @@ +import { ChatText, Check, FunnelSimple, X } from "@phosphor-icons/react"; +import { + Autocomplete, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + Dialog, + DialogContent, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@posthog/quill"; +import { CommandKeyHints } from "@posthog/ui/features/command/CommandKeyHints"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { Flex } from "@radix-ui/themes"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface MessageJumpPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + items: ConversationItem[]; + /** Scrolls the thread to the message with this id (ChatThread addresses rows by id, not index). */ + onJumpToMessage: (id: string) => void; +} + +interface JumpEntry { + id: string; + label: string; + fullText: string; + timestamp: number; +} + +type DatePreset = + | "today" + | "yesterday" + | "last7d" + | "last14d" + | "thisMonth" + | "lastMonth" + | "last30d" + | "last90d" + | "last6mo" + | "thisYear" + | "lastYear"; + +interface PresetConfig { + label: string; + shortLabel: string; + footerLabel: string; + getRange: () => { from: number; to: number }; +} + +const DATE_PRESETS: Record = { + today: { + label: "Today", + shortLabel: "Today", + footerLabel: "today", + getRange: () => { + const start = new Date(); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + yesterday: { + label: "Yesterday", + shortLabel: "Yest.", + footerLabel: "yesterday", + getRange: () => { + const d = new Date(); + d.setDate(d.getDate() - 1); + const start = new Date(d); + start.setHours(0, 0, 0, 0); + const end = new Date(d); + end.setHours(23, 59, 59, 999); + return { from: start.getTime(), to: end.getTime() }; + }, + }, + last7d: { + label: "Last 7 days", + shortLabel: "7d", + footerLabel: "last 7 days", + getRange: () => { + const start = new Date(); + start.setDate(start.getDate() - 7); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + last14d: { + label: "Last 14 days", + shortLabel: "14d", + footerLabel: "last 14 days", + getRange: () => { + const start = new Date(); + start.setDate(start.getDate() - 14); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + thisMonth: { + label: "This month", + shortLabel: "This mo", + footerLabel: "this month", + getRange: () => { + const start = new Date(); + start.setDate(1); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + lastMonth: { + label: "Last month", + shortLabel: "Last mo", + footerLabel: "last month", + getRange: () => { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const end = new Date( + now.getFullYear(), + now.getMonth(), + 0, + 23, + 59, + 59, + 999, + ); + return { from: start.getTime(), to: end.getTime() }; + }, + }, + last30d: { + label: "Last 30 days", + shortLabel: "30d", + footerLabel: "last 30 days", + getRange: () => { + const start = new Date(); + start.setDate(start.getDate() - 30); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + last90d: { + label: "Last 90 days", + shortLabel: "90d", + footerLabel: "last 90 days", + getRange: () => { + const start = new Date(); + start.setDate(start.getDate() - 90); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + last6mo: { + label: "Last 6 months", + shortLabel: "6mo", + footerLabel: "last 6 months", + getRange: () => { + const start = new Date(); + start.setMonth(start.getMonth() - 6); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + thisYear: { + label: "This year", + shortLabel: "This yr", + footerLabel: "this year", + getRange: () => { + const start = new Date(); + start.setMonth(0, 1); + start.setHours(0, 0, 0, 0); + return { from: start.getTime(), to: Date.now() }; + }, + }, + lastYear: { + label: "Last year", + shortLabel: "Last yr", + footerLabel: "last year", + getRange: () => { + const year = new Date().getFullYear() - 1; + const start = new Date(year, 0, 1); + const end = new Date(year, 11, 31, 23, 59, 59, 999); + return { from: start.getTime(), to: end.getTime() }; + }, + }, +}; + +const PRESET_ORDER: DatePreset[] = [ + "today", + "yesterday", + "last7d", + "last14d", + "thisMonth", + "lastMonth", + "last30d", + "last90d", + "last6mo", + "thisYear", + "lastYear", +]; + +const MAX_LABEL_LENGTH = 120; + +function formatTimestamp(ts: number): string { + return new Date(ts).toLocaleString([], { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatDateTimeShort(datetimeStr: string): string { + return new Date(datetimeStr).toLocaleString([], { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function truncate(text: string, maxLength: number): string { + const singleLine = text.replace(/\n+/g, " ").trim(); + if (singleLine.length <= maxLength) return singleLine; + return `${singleLine.slice(0, maxLength)}…`; +} + +export function MessageJumpPicker({ + open, + onOpenChange, + items, + onJumpToMessage, +}: MessageJumpPickerProps) { + const [query, setQuery] = useState(""); + const [activePreset, setActivePreset] = useState(null); + const [showCustom, setShowCustom] = useState(false); + const [customFrom, setCustomFrom] = useState(""); + const [customTo, setCustomTo] = useState(""); + + useEffect(() => { + if (!open) { + setQuery(""); + setActivePreset(null); + setShowCustom(false); + setCustomFrom(""); + setCustomTo(""); + } + }, [open]); + + const entries = useMemo(() => { + const result: JumpEntry[] = []; + for (const item of items) { + if (item.type === "user_message") { + result.push({ + id: item.id, + label: truncate(item.content, MAX_LABEL_LENGTH), + fullText: item.content, + timestamp: item.timestamp, + }); + } + } + return result; + }, [items]); + + const visibleEntries = useMemo(() => { + let filtered = entries; + + if (activePreset !== null) { + const { from, to } = DATE_PRESETS[activePreset].getRange(); + filtered = filtered.filter( + (e) => e.timestamp >= from && e.timestamp <= to, + ); + } else if (showCustom) { + if (customFrom) { + filtered = filtered.filter( + (e) => e.timestamp >= new Date(customFrom).getTime(), + ); + } + if (customTo) { + filtered = filtered.filter( + (e) => e.timestamp <= new Date(customTo).getTime(), + ); + } + } + + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery) { + filtered = filtered.filter((entry) => + entry.fullText.toLowerCase().includes(normalizedQuery), + ); + } + + return filtered; + }, [entries, query, activePreset, showCustom, customFrom, customTo]); + + const footerFilterLabel = useMemo((): string | null => { + if (activePreset !== null) return DATE_PRESETS[activePreset].footerLabel; + if (showCustom) { + if (customFrom && customTo) { + return `${formatDateTimeShort(customFrom)} – ${formatDateTimeShort(customTo)}`; + } + if (customFrom) return `after ${formatDateTimeShort(customFrom)}`; + if (customTo) return `before ${formatDateTimeShort(customTo)}`; + } + return null; + }, [activePreset, showCustom, customFrom, customTo]); + + const triggerLabel = useMemo((): string => { + if (activePreset !== null) return DATE_PRESETS[activePreset].shortLabel; + if (showCustom && (customFrom || customTo)) return "Custom"; + return "Filter"; + }, [activePreset, showCustom, customFrom, customTo]); + + const filterActive = + activePreset !== null || + (showCustom && (customFrom !== "" || customTo !== "")); + + const handlePreset = useCallback((preset: DatePreset) => { + setActivePreset((current) => (current === preset ? null : preset)); + setShowCustom(false); + setCustomFrom(""); + setCustomTo(""); + }, []); + + const handleCustom = useCallback(() => { + setActivePreset(null); + setShowCustom(true); + }, []); + + const clearCustom = useCallback(() => { + setCustomFrom(""); + setCustomTo(""); + setShowCustom(false); + }, []); + + const handleSelect = useCallback( + (id: string | null) => { + if (id === null) return; + const entry = visibleEntries.find((e) => e.id === id); + if (!entry) return; + onJumpToMessage(entry.id); + onOpenChange(false); + }, + [visibleEntries, onJumpToMessage, onOpenChange], + ); + + return ( + + + + inline + defaultOpen + items={visibleEntries} + filter={null} + value={query} + autoHighlight="always" + onValueChange={(val, eventDetails) => { + if (eventDetails.reason !== "input-change") return; + if (typeof val === "string") { + setQuery(val); + } + }} + > + +
+ + + + + {triggerLabel} + + } + /> + +
+ {PRESET_ORDER.map((preset) => ( + handlePreset(preset)} + className="flex items-center justify-between" + > + {DATE_PRESETS[preset].label} + {activePreset === preset && ( + + )} + + ))} +
+ + + Custom range… + +
+
+
+
+
+ + {showCustom && ( + + setCustomFrom(e.target.value)} + className="h-[22px] min-w-0 flex-1 rounded-(--radius-1) border border-(--gray-a5) bg-(--gray-a2) px-1.5 text-(--gray-12) text-[12px] tabular-nums outline-none focus:border-(--accent-8)" + /> + – + setCustomTo(e.target.value)} + className="h-[22px] min-w-0 flex-1 rounded-(--radius-1) border border-(--gray-a5) bg-(--gray-a2) px-1.5 text-(--gray-12) text-[12px] tabular-nums outline-none focus:border-(--accent-8)" + /> + + + )} + + + {(entry: JumpEntry) => ( + handleSelect(entry.id)} + className="group/entry h-auto! min-h-7 py-1.5 text-left" + > + + + {entry.label} + + + {formatTimestamp(entry.timestamp)} + + + )} + + + + + {visibleEntries.length}{" "} + {visibleEntries.length === 1 ? "message" : "messages"} + {footerFilterLabel !== null && ( + + ({footerFilterLabel}) + + )} + + + +
+
+ ); +} From f733e636c2c13cfa56ae8f9a61981e5041be10e5 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Sun, 12 Jul 2026 14:34:42 +0100 Subject: [PATCH 23/23] fix(sessions): ChatThread focus ring + jump-picker filter labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the keyboard-focus indicator from ChatMessage (the outer row wrapper) to ChatBubble (the actual visible bubble) — it was tracing a box wider than what's painted, reading as disconnected corner brackets. Match the bubble's own 8px radius so the ring's corners round cleanly instead of clashing with the bubble underneath (this was also the root cause of an apparent top-edge clipping artifact). Use a brighter, higher-contrast neutral (gray-11) instead of the app's warm yellow/orange brand accent, which reads as jarring against the new rounded-bubble design — not a fit for every accent-colored highlight. MessageJumpPicker's date-filter trigger now shows the full preset label ("Last month") instead of an abbreviated one ("Last mo"); shortLabel had no other consumer so it's removed. --- .../components/chat-thread/ChatThread.tsx | 17 +++++++++-------- .../chat-thread/MessageJumpPicker.tsx | 14 +------------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index d07ddc85d9..066a28d624 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -257,13 +257,7 @@ function UserBubble({ }, [displayContent, isExpanded]); return ( - + {showHeaderChips && ( @@ -302,7 +296,14 @@ function UserBubble({ )} )} - +
{ from: number; to: number }; } @@ -55,7 +54,6 @@ interface PresetConfig { const DATE_PRESETS: Record = { today: { label: "Today", - shortLabel: "Today", footerLabel: "today", getRange: () => { const start = new Date(); @@ -65,7 +63,6 @@ const DATE_PRESETS: Record = { }, yesterday: { label: "Yesterday", - shortLabel: "Yest.", footerLabel: "yesterday", getRange: () => { const d = new Date(); @@ -79,7 +76,6 @@ const DATE_PRESETS: Record = { }, last7d: { label: "Last 7 days", - shortLabel: "7d", footerLabel: "last 7 days", getRange: () => { const start = new Date(); @@ -90,7 +86,6 @@ const DATE_PRESETS: Record = { }, last14d: { label: "Last 14 days", - shortLabel: "14d", footerLabel: "last 14 days", getRange: () => { const start = new Date(); @@ -101,7 +96,6 @@ const DATE_PRESETS: Record = { }, thisMonth: { label: "This month", - shortLabel: "This mo", footerLabel: "this month", getRange: () => { const start = new Date(); @@ -112,7 +106,6 @@ const DATE_PRESETS: Record = { }, lastMonth: { label: "Last month", - shortLabel: "Last mo", footerLabel: "last month", getRange: () => { const now = new Date(); @@ -131,7 +124,6 @@ const DATE_PRESETS: Record = { }, last30d: { label: "Last 30 days", - shortLabel: "30d", footerLabel: "last 30 days", getRange: () => { const start = new Date(); @@ -142,7 +134,6 @@ const DATE_PRESETS: Record = { }, last90d: { label: "Last 90 days", - shortLabel: "90d", footerLabel: "last 90 days", getRange: () => { const start = new Date(); @@ -153,7 +144,6 @@ const DATE_PRESETS: Record = { }, last6mo: { label: "Last 6 months", - shortLabel: "6mo", footerLabel: "last 6 months", getRange: () => { const start = new Date(); @@ -164,7 +154,6 @@ const DATE_PRESETS: Record = { }, thisYear: { label: "This year", - shortLabel: "This yr", footerLabel: "this year", getRange: () => { const start = new Date(); @@ -175,7 +164,6 @@ const DATE_PRESETS: Record = { }, lastYear: { label: "Last year", - shortLabel: "Last yr", footerLabel: "last year", getRange: () => { const year = new Date().getFullYear() - 1; @@ -310,7 +298,7 @@ export function MessageJumpPicker({ }, [activePreset, showCustom, customFrom, customTo]); const triggerLabel = useMemo((): string => { - if (activePreset !== null) return DATE_PRESETS[activePreset].shortLabel; + if (activePreset !== null) return DATE_PRESETS[activePreset].label; if (showCustom && (customFrom || customTo)) return "Custom"; return "Filter"; }, [activePreset, showCustom, customFrom, customTo]);