From 41c6d6873d7fcaa298d53499fac7af32ffa27c55 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 16:46:38 +0100 Subject: [PATCH 01/13] 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 6bfdc0f790..a8d2ea448a 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -35,6 +35,7 @@ export interface KeyboardShortcut { category: ShortcutCategory; context?: string; alternateKeys?: string; + configurable?: boolean; } export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ @@ -44,30 +45,35 @@ 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: "inbox", keys: SHORTCUTS.INBOX, description: "Open inbox", category: "navigation", + configurable: true, }, { id: "switch-task", @@ -81,6 +87,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous task", category: "navigation", alternateKeys: "ctrl+shift+tab", + configurable: true, }, { id: "next-task", @@ -88,42 +95,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", @@ -138,6 +152,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Close active tab", category: "panels", context: "Task detail", + configurable: true, }, { id: "open-in-editor", @@ -145,6 +160,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Open in external editor", category: "panels", context: "Task detail", + configurable: true, }, { id: "copy-path", @@ -152,6 +168,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", @@ -218,6 +243,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 eade45a9f9..6f2796946f 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, @@ -59,8 +60,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(); @@ -70,7 +74,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 0de109e669aa6da5efd1a34a25b9334775482024 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 19:14:51 +0100 Subject: [PATCH 02/13] 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 0bfe5ef2776c90c2cc5d7a18dd06dd4214de5344 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 19:24:44 +0100 Subject: [PATCH 03/13] 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 2edccdd773a5d1a4dd5bb143c17c9b129106239c Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 21:24:30 +0100 Subject: [PATCH 04/13] 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 6f2796946f..6f502a8619 100644 --- a/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx +++ b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx @@ -14,7 +14,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"; @@ -141,7 +141,9 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { {app.name} {app.id === defaultApp?.id && ( - ⌘O + {formatHotkeyParts(openInEditorKey).map((part) => ( + {part} + ))} )} @@ -151,7 +153,9 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { Copy Path - ⌘⇧C + {formatHotkeyParts(copyPathKey).map((part) => ( + {part} + ))} 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 cf85f2d7248314134e1dd022f1d0f8c81f4fe27f Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Thu, 21 May 2026 22:54:53 +0100 Subject: [PATCH 05/13] 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 3bc8b7e7b3..8a2853ef11 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 6fe05d71ac..942766d68b 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -30,6 +30,7 @@ import { Switch, Text, } from "@radix-ui/themes"; +import { formatHotkey } from "../../command/keyboard-shortcuts"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; import { toast } from "sonner"; @@ -457,7 +458,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 7745218e0e659078d3cffa5215ba399157d4d1e0 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Sat, 23 May 2026 01:12:36 +0100 Subject: [PATCH 06/13] 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 a8d2ea448a..196c1a49b1 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", } as const; @@ -185,12 +186,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", @@ -198,6 +208,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous prompt (when input is empty)", category: "editor", context: "Message editor", + configurable: true, }, { id: "prompt-history-next", @@ -205,6 +216,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Next prompt (when input is empty)", category: "editor", context: "Message editor", + configurable: true, }, { id: "editor-bold", @@ -261,6 +273,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]; @@ -283,6 +299,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< @@ -301,6 +321,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 8a2853ef11..1c7c35f03c 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 f834a86a61..611245f01d 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"; @@ -60,6 +61,7 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { : effectiveRepoPath; const [filePickerOpen, setFilePickerOpen] = useState(false); + const filePickerKey = useShortcut("file-picker"); const { enableScope, disableScope } = useHotkeysContext(); @@ -70,7 +72,7 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { }; }, [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 0c5fccd279f05f266ddb36f7c30a8fe01b6194ba Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Sat, 23 May 2026 03:29:58 +0100 Subject: [PATCH 07/13] 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 1c7c35f03c..d8b446fb0e 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 0b58f2b2edea4d0cd20d0df703d6da116ecd0367 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 15:59:12 +0100 Subject: [PATCH 09/13] 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 4bc6dd3dab603d8ab090251ae881fac6d801f0b3 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 16:23:58 +0100 Subject: [PATCH 10/13] 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 2b9aaef61ec5d5916289331bf46e8823db85a39d Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 25 May 2026 20:53:45 +0100 Subject: [PATCH 11/13] feat(code): add conversation message navigation shortcuts --- .../features/command/keyboard-shortcuts.ts | 33 ++++ .../sessions/components/ConversationView.tsx | 101 ++++++++++- .../sessions/components/MessageJumpPicker.tsx | 165 ++++++++++++++++++ .../components/session-update/UserMessage.tsx | 4 +- 4 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/MessageJumpPicker.tsx diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 196c1a49b1..34b18a422b 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", } as const; @@ -194,6 +197,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, @@ -274,6 +301,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", @@ -300,6 +330,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/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 7ff2371aca..e1f01ad3b3 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -1,3 +1,4 @@ +import { useShortcut } from "../../../primitives/hooks/useShortcut"; import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; @@ -17,6 +18,7 @@ import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; +import { MessageJumpPicker } from "./MessageJumpPicker"; import { buildThreadGroups, type ThreadGrouping, @@ -59,6 +61,7 @@ import { } from "@posthog/ui/shell/diffWorkerHost"; import { Box, Flex, Text } from "@radix-ui/themes"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; const DIFFS_HIGHLIGHTER_OPTIONS = { theme: { dark: "github-dark" as const, light: "github-light" as const }, @@ -166,6 +169,17 @@ export function ConversationView({ [conversationItems, optimisticItems, queuedItems, isCloud], ); + const userMessages = useMemo(() => { + const result: Array<{ id: string; index: number }> = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type === "user_message") { + result.push({ id: item.id, index: i }); + } + } + return result; + }, [items]); + // Fold each completed turn's tool-call work into a collapsible chip, and emit // the keepMounted indices (standalone MCP-app rows, whose iframes must survive // scrolling) + the item→row map in the same pass. @@ -207,6 +221,77 @@ export function ConversationView({ listRef: searchListRef, }); + 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"); + useHotkeys(messageJumpKey, () => setJumpPickerOpen(true), { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }); + + const handleNavigateMessage = useCallback( + (direction: -1 | 1) => { + if (userMessages.length === 0) return; + + const currentIndex = keyboardFocusedMessageId + ? userMessages.findIndex( + (message) => message.id === keyboardFocusedMessageId, + ) + : -1; + + const nextIndex = + currentIndex === -1 + ? direction > 0 + ? 0 + : userMessages.length - 1 + : Math.max( + 0, + Math.min(userMessages.length - 1, currentIndex + direction), + ); + + const nextMessage = userMessages[nextIndex]; + if (!nextMessage) return; + + setKeyboardFocusedMessageId(nextMessage.id); + listRef.current?.scrollToIndex(nextMessage.index); + }, + [keyboardFocusedMessageId, userMessages], + ); + + useHotkeys(previousMessageKey, () => handleNavigateMessage(-1), { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }); + + useHotkeys(nextMessageKey, () => handleNavigateMessage(1), { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }); + + useEffect(() => { + if ( + keyboardFocusedMessageId && + !userMessages.some((message) => message.id === keyboardFocusedMessageId) + ) { + setKeyboardFocusedMessageId(null); + } + }, [keyboardFocusedMessageId, userMessages]); + + const clearKeyboardFocus = useCallback(() => { + setKeyboardFocusedMessageId(null); + }, []); + + const handleJumpToIndex = useCallback((index: number) => { + listRef.current?.scrollToIndex(index); + }, []); + const handleScrollStateChange = useCallback((isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom; setShowScrollButton(!isAtBottom); @@ -241,6 +326,7 @@ export function ConversationView({ timestamp={item.timestamp} animate={!initialItemIds.has(item.id)} taskId={taskId} + keyboardFocused={item.id === keyboardFocusedMessageId} sourceUrl={ slackThreadUrl && item.id === firstUserMessageId ? slackThreadUrl @@ -289,7 +375,7 @@ export function ConversationView({ ); } }, - [repoPath, taskId, slackThreadUrl, firstUserMessageId, initialItemIds], + [repoPath, taskId, slackThreadUrl, firstUserMessageId, initialItemIds, keyboardFocusedMessageId], ); const getRowKey = useCallback((row: ThreadRow) => row.id, []); @@ -357,7 +443,11 @@ export function ConversationView({ poolOptions={diffsPoolOptions} highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS} > -
+
)} + + ref={listRef} diff --git a/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx b/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx new file mode 100644 index 0000000000..86c3cf9c2a --- /dev/null +++ b/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx @@ -0,0 +1,165 @@ +import { CommandKeyHints } from "../../command/CommandKeyHints"; +import type { ConversationItem } from "./buildConversationItems"; +import { ChatText } from "@phosphor-icons/react"; +import { + Autocomplete, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + Dialog, + DialogContent, +} from "@posthog/quill"; +import { Flex } from "@radix-ui/themes"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface MessageJumpPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + items: ConversationItem[]; + onJumpToIndex: (index: number) => void; +} + +interface JumpEntry { + id: string; + label: string; + fullText: string; + timestamp: number; + index: number; +} + +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 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, + onJumpToIndex, +}: MessageJumpPickerProps) { + const [query, setQuery] = useState(""); + + useEffect(() => { + if (!open) { + setQuery(""); + } + }, [open]); + + const entries = useMemo(() => { + const result: JumpEntry[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type === "user_message") { + result.push({ + id: item.id, + label: truncate(item.content, MAX_LABEL_LENGTH), + fullText: item.content, + timestamp: item.timestamp, + index: i, + }); + } + } + return result; + }, [items]); + + const visibleEntries = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return entries; + return entries.filter((entry) => + entry.fullText.toLowerCase().includes(normalizedQuery), + ); + }, [entries, query]); + + const allEntries = visibleEntries; + + const handleSelect = useCallback( + (id: string | null) => { + if (id === null) return; + const entry = allEntries.find((e) => e.id === id); + if (!entry) return; + onJumpToIndex(entry.index); + onOpenChange(false); + }, + [allEntries, onJumpToIndex, 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); + } + }} + > + +
+ +
+
+ + {(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} messages + + + +
+
+ ); +} diff --git a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx index db0b50db4a..2aab46dc14 100644 --- a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx @@ -33,6 +33,7 @@ interface UserMessageProps { animate?: boolean; /** Task the message belongs to — needed to open the context file tab. */ taskId?: string; + keyboardFocused?: boolean; } function formatTimestamp(ts: number): string { @@ -58,6 +59,7 @@ export const UserMessage = memo(function UserMessage({ attachments = [], animate = true, taskId, + keyboardFocused = false, }: UserMessageProps) { // A channel's CONTEXT.md, if injected into this prompt, is collapsed into a // clickable tag instead of rendered inline; the rest of the prompt renders @@ -112,7 +114,7 @@ export const UserMessage = memo(function UserMessage({ transition={animate ? { duration: 0.25, ease: "easeOut" } : undefined} > Date: Mon, 1 Jun 2026 13:39:53 +0100 Subject: [PATCH 12/13] feat(code): add date range filter to jump-to-message picker Users can now filter conversation messages by date range directly in the jump picker. The filter row sits between the search input and the message list, showing two compact date inputs (From / To) with a clear button that appears when a filter is active. The message count in the footer reflects the active filter and notes when date filtering is on. Bounds for each input are derived from the earliest/latest message timestamps so the native date picker calendar starts at a sensible range. --- .../sessions/components/MessageJumpPicker.tsx | 131 ++++++++++++++++-- 1 file changed, 119 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx b/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx index 86c3cf9c2a..ada4a062c0 100644 --- a/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx +++ b/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx @@ -1,6 +1,6 @@ import { CommandKeyHints } from "../../command/CommandKeyHints"; import type { ConversationItem } from "./buildConversationItems"; -import { ChatText } from "@phosphor-icons/react"; +import { CalendarBlank, ChatText, X } from "@phosphor-icons/react"; import { Autocomplete, AutocompleteInput, @@ -9,7 +9,7 @@ import { Dialog, DialogContent, } from "@posthog/quill"; -import { Flex } from "@radix-ui/themes"; +import { Flex, IconButton, Tooltip } from "@radix-ui/themes"; import { useCallback, useEffect, useMemo, useState } from "react"; interface MessageJumpPickerProps { @@ -40,6 +40,26 @@ function formatTimestamp(ts: number): string { }); } +function toLocalDateInputValue(ts: number): string { + const d = new Date(ts); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function startOfDay(dateStr: string): number { + const d = new Date(dateStr); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +function endOfDay(dateStr: string): number { + const d = new Date(dateStr); + d.setHours(23, 59, 59, 999); + return d.getTime(); +} + function truncate(text: string, maxLength: number): string { const singleLine = text.replace(/\n+/g, " ").trim(); if (singleLine.length <= maxLength) return singleLine; @@ -53,10 +73,14 @@ export function MessageJumpPicker({ onJumpToIndex, }: MessageJumpPickerProps) { const [query, setQuery] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); useEffect(() => { if (!open) { setQuery(""); + setDateFrom(""); + setDateTo(""); } }, [open]); @@ -77,25 +101,55 @@ export function MessageJumpPicker({ return result; }, [items]); + const dateFilterActive = dateFrom !== "" || dateTo !== ""; + + const clearDateFilter = useCallback(() => { + setDateFrom(""); + setDateTo(""); + }, []); + const visibleEntries = useMemo(() => { + let filtered = entries; + + if (dateFrom !== "") { + const fromMs = startOfDay(dateFrom); + filtered = filtered.filter((e) => e.timestamp >= fromMs); + } + if (dateTo !== "") { + const toMs = endOfDay(dateTo); + filtered = filtered.filter((e) => e.timestamp <= toMs); + } + const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return entries; - return entries.filter((entry) => - entry.fullText.toLowerCase().includes(normalizedQuery), - ); - }, [entries, query]); + if (normalizedQuery) { + filtered = filtered.filter((entry) => + entry.fullText.toLowerCase().includes(normalizedQuery), + ); + } + + return filtered; + }, [entries, query, dateFrom, dateTo]); - const allEntries = visibleEntries; + // Derive min/max from actual message timestamps for date picker bounds + const { minDate, maxDate } = useMemo(() => { + if (entries.length === 0) return { minDate: undefined, maxDate: undefined }; + const min = Math.min(...entries.map((e) => e.timestamp)); + const max = Math.max(...entries.map((e) => e.timestamp)); + return { + minDate: toLocalDateInputValue(min), + maxDate: toLocalDateInputValue(max), + }; + }, [entries]); const handleSelect = useCallback( (id: string | null) => { if (id === null) return; - const entry = allEntries.find((e) => e.id === id); + const entry = visibleEntries.find((e) => e.id === id); if (!entry) return; onJumpToIndex(entry.index); onOpenChange(false); }, - [allEntries, onJumpToIndex, onOpenChange], + [visibleEntries, onJumpToIndex, onOpenChange], ); return ( @@ -127,7 +181,54 @@ export function MessageJumpPicker({ />
- + + {/* Date filter row */} + + + Filter by date + + setDateFrom(e.target.value)} + className="h-[22px] 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) focus:ring-0" + /> + + setDateTo(e.target.value)} + className="h-[22px] 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) focus:ring-0" + /> + + {dateFilterActive && ( + + + + + + )} + + + {(entry: JumpEntry) => ( - {visibleEntries.length} messages + {visibleEntries.length}{" "} + {visibleEntries.length === 1 ? "message" : "messages"} + {dateFilterActive && ( + + (date filter active) + + )} From efd89322f085288435ee7aeb5d80b067ba5529d7 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 2 Jun 2026 05:25:40 +0100 Subject: [PATCH 13/13] feat(code): redesign jump picker date filter with preset dropdown Replace the separate date-range row with a funnel icon trigger inside the search input that opens a dropdown of date presets. Adds 11 presets (Today through Last year) with a Custom range option using datetime-local inputs. Footer shows the active filter in human-readable form. Also fixes ConversationScrollbar hooks-order violation (early return before useCallback/useEffect calls), and removes dangling selectDiscoveredTask call in InboxSignalsTab that referenced a store method that was never implemented. --- .../sessions/components/MessageJumpPicker.tsx | 418 ++++++++++++++---- 1 file changed, 324 insertions(+), 94 deletions(-) diff --git a/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx b/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx index ada4a062c0..dea262e3a5 100644 --- a/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx +++ b/packages/ui/src/features/sessions/components/MessageJumpPicker.tsx @@ -1,6 +1,6 @@ import { CommandKeyHints } from "../../command/CommandKeyHints"; import type { ConversationItem } from "./buildConversationItems"; -import { CalendarBlank, ChatText, X } from "@phosphor-icons/react"; +import { ChatText, Check, FunnelSimple, X } from "@phosphor-icons/react"; import { Autocomplete, AutocompleteInput, @@ -8,8 +8,13 @@ import { AutocompleteList, Dialog, DialogContent, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@posthog/quill"; -import { Flex, IconButton, Tooltip } from "@radix-ui/themes"; +import { Flex } from "@radix-ui/themes"; import { useCallback, useEffect, useMemo, useState } from "react"; interface MessageJumpPickerProps { @@ -27,6 +32,174 @@ interface JumpEntry { index: 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 { @@ -40,24 +213,14 @@ function formatTimestamp(ts: number): string { }); } -function toLocalDateInputValue(ts: number): string { - const d = new Date(ts); - const year = d.getFullYear(); - const month = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - -function startOfDay(dateStr: string): number { - const d = new Date(dateStr); - d.setHours(0, 0, 0, 0); - return d.getTime(); -} - -function endOfDay(dateStr: string): number { - const d = new Date(dateStr); - d.setHours(23, 59, 59, 999); - return d.getTime(); +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 { @@ -73,14 +236,18 @@ export function MessageJumpPicker({ onJumpToIndex, }: MessageJumpPickerProps) { const [query, setQuery] = useState(""); - const [dateFrom, setDateFrom] = useState(""); - const [dateTo, setDateTo] = useState(""); + const [activePreset, setActivePreset] = useState(null); + const [showCustom, setShowCustom] = useState(false); + const [customFrom, setCustomFrom] = useState(""); + const [customTo, setCustomTo] = useState(""); useEffect(() => { if (!open) { setQuery(""); - setDateFrom(""); - setDateTo(""); + setActivePreset(null); + setShowCustom(false); + setCustomFrom(""); + setCustomTo(""); } }, [open]); @@ -101,23 +268,25 @@ export function MessageJumpPicker({ return result; }, [items]); - const dateFilterActive = dateFrom !== "" || dateTo !== ""; - - const clearDateFilter = useCallback(() => { - setDateFrom(""); - setDateTo(""); - }, []); - const visibleEntries = useMemo(() => { let filtered = entries; - if (dateFrom !== "") { - const fromMs = startOfDay(dateFrom); - filtered = filtered.filter((e) => e.timestamp >= fromMs); - } - if (dateTo !== "") { - const toMs = endOfDay(dateTo); - filtered = filtered.filter((e) => e.timestamp <= toMs); + 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(); @@ -128,18 +297,47 @@ export function MessageJumpPicker({ } return filtered; - }, [entries, query, dateFrom, dateTo]); + }, [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]); - // Derive min/max from actual message timestamps for date picker bounds - const { minDate, maxDate } = useMemo(() => { - if (entries.length === 0) return { minDate: undefined, maxDate: undefined }; - const min = Math.min(...entries.map((e) => e.timestamp)); - const max = Math.max(...entries.map((e) => e.timestamp)); - return { - minDate: toLocalDateInputValue(min), - maxDate: toLocalDateInputValue(max), - }; - }, [entries]); + 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) => { @@ -172,61 +370,93 @@ export function MessageJumpPicker({ } }} > - -
+ +
+ > + + + + {triggerLabel} + + } + /> + +
+ {PRESET_ORDER.map((preset) => ( + handlePreset(preset)} + className="flex items-center justify-between" + > + {DATE_PRESETS[preset].label} + {activePreset === preset && ( + + )} + + ))} +
+ + + Custom range… + +
+
+
- {/* Date filter row */} - - - Filter by date - + {showCustom && ( + setDateFrom(e.target.value)} - className="h-[22px] 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) focus:ring-0" + type="datetime-local" + value={customFrom} + max={customTo || undefined} + onChange={(e) => 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)" /> - + setDateTo(e.target.value)} - className="h-[22px] 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) focus:ring-0" + type="datetime-local" + value={customTo} + min={customFrom || undefined} + onChange={(e) => 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)" /> + - {dateFilterActive && ( - - - - - - )} - + )} {(entry: JumpEntry) => ( @@ -258,9 +488,9 @@ export function MessageJumpPicker({ {visibleEntries.length}{" "} {visibleEntries.length === 1 ? "message" : "messages"} - {dateFilterActive && ( + {footerFilterLabel !== null && ( - (date filter active) + ({footerFilterLabel}) )}