diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 1c0008732..98fc4da05 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -954,6 +954,14 @@ When creating pull requests, add the following footer at the end of the PR descr return this.sessions.get(taskRunId); } + getSessionInfo( + taskRunId: string, + ): { sessionId: string; repoPath: string } | undefined { + const session = this.sessions.get(taskRunId); + if (!session?.config.sessionId) return undefined; + return { sessionId: session.config.sessionId, repoPath: session.repoPath }; + } + async setSessionConfigOption( sessionId: string, configId: string, diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75a5c85c2..e38bf6af1 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -1,5 +1,6 @@ import { agentRouter } from "./routers/agent"; import { analyticsRouter } from "./routers/analytics"; +import { checkpointRouter } from "./routers/checkpoint"; import { archiveRouter } from "./routers/archive"; import { authRouter } from "./routers/auth"; import { cloudTaskRouter } from "./routers/cloud-task"; @@ -42,6 +43,7 @@ export const trpcRouter = router({ analytics: analyticsRouter, archive: archiveRouter, auth: authRouter, + checkpoint: checkpointRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, diff --git a/apps/code/src/main/trpc/routers/checkpoint.ts b/apps/code/src/main/trpc/routers/checkpoint.ts new file mode 100644 index 000000000..22889e3f4 --- /dev/null +++ b/apps/code/src/main/trpc/routers/checkpoint.ts @@ -0,0 +1,216 @@ +import fs from "node:fs/promises"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; +import { getSessionJsonlPath } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; +import { createGitClient } from "@posthog/git/client"; +import { + deleteCheckpoint, + RevertCheckpointSaga, +} from "@posthog/git/sagas/checkpoint"; +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { AgentService } from "../../services/agent/service"; +import { logger } from "../../utils/logger"; +import { publicProcedure, router } from "../trpc"; + +const log = logger.scope("checkpoint-router"); + +const getAgentService = () => + container.get(MAIN_TOKENS.AgentService); + +const restoreInput = z.object({ + checkpointId: z.string(), + repoPath: z.string(), + taskRunId: z.string().optional(), +}); + +const restoreOutput = z.object({ + checkpointId: z.string(), + commit: z.string(), + head: z.string().nullable(), + branch: z.string().nullable(), +}); + +interface TruncateResult { + truncated: boolean; + /** Checkpoint IDs that appear in the discarded portion (orphaned refs). */ + orphanedCheckpointIds: string[]; +} + +/** + * Truncate a session JSONL file at the turn containing the given checkpoint. + * Finds the `_posthog/git_checkpoint` entry with matching checkpointId, then + * includes all entries up to (but not including) the next user message group. + * Returns orphaned checkpoint IDs from the discarded lines for cleanup. + */ +async function truncateSessionJsonl( + jsonlPath: string, + checkpointId: string, +): Promise { + let content: string; + try { + content = await fs.readFile(jsonlPath, "utf-8"); + } catch { + return { truncated: false, orphanedCheckpointIds: [] }; + } + + const lines = content.split("\n").filter((l) => l.trim()); + let checkpointLineIdx = -1; + + for (let i = 0; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + const method = entry.notification?.method; + if (!method) continue; + if (!isNotification(method, POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT)) + continue; + const params = entry.notification?.params; + if (params?.checkpointId === checkpointId) { + checkpointLineIdx = i; + break; + } + } catch { + // skip malformed lines + } + } + + if (checkpointLineIdx === -1) + return { truncated: false, orphanedCheckpointIds: [] }; + + // Find the end of the current turn: scan forward for the next user message + // group start (a user_message_chunk after non-user content). + let cutoff = lines.length; + let inUserMessage = false; + let passedNonUser = false; + + for (let i = checkpointLineIdx + 1; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + const method = entry.notification?.method; + const params = entry.notification?.params as + | Record + | undefined; + + if (method === "session/update" && params?.update) { + const update = params.update as { sessionUpdate?: string }; + const isUserChunk = + update.sessionUpdate === "user_message" || + update.sessionUpdate === "user_message_chunk"; + + if (isUserChunk) { + if (passedNonUser && !inUserMessage) { + // Start of a new user message group — stop here + cutoff = i; + break; + } + inUserMessage = true; + } else { + if (inUserMessage) { + passedNonUser = true; + } + inUserMessage = false; + } + } else if (method === "session/prompt") { + // session/prompt request also marks a turn boundary + cutoff = i; + break; + } else { + if (inUserMessage) { + passedNonUser = true; + } + inUserMessage = false; + } + } catch { + // skip malformed + } + } + + // Collect checkpoint IDs from the discarded lines so their refs can be cleaned up + const orphanedCheckpointIds: string[] = []; + for (let i = cutoff; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + const method = entry.notification?.method; + if (!method) continue; + if (!isNotification(method, POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT)) + continue; + const id = entry.notification?.params?.checkpointId; + if (id) orphanedCheckpointIds.push(id); + } catch { + // skip malformed + } + } + + const truncatedLines = lines.slice(0, cutoff); + const tmpPath = `${jsonlPath}.tmp.${Date.now()}`; + await fs.writeFile(tmpPath, `${truncatedLines.join("\n")}\n`, "utf-8"); + await fs.rename(tmpPath, jsonlPath); + + log.info("Truncated session JSONL", { + checkpointId, + originalLines: lines.length, + truncatedLines: truncatedLines.length, + orphanedCheckpointIds, + }); + return { truncated: true, orphanedCheckpointIds }; +} + +export const checkpointRouter = router({ + restore: publicProcedure + .input(restoreInput) + .output(restoreOutput) + .mutation(async ({ input }) => { + // 1. Revert git files to checkpoint state + const saga = new RevertCheckpointSaga(); + const result = await saga.execute({ + baseDir: input.repoPath, + checkpointId: input.checkpointId, + }); + + // 2. Truncate agent's session JSONL, clean up orphaned checkpoint refs, and restart agent + if (input.taskRunId) { + try { + const agentService = getAgentService(); + const info = agentService.getSessionInfo(input.taskRunId); + if (info) { + const jsonlPath = getSessionJsonlPath( + info.sessionId, + info.repoPath, + ); + const { truncated, orphanedCheckpointIds } = + await truncateSessionJsonl(jsonlPath, input.checkpointId); + if (truncated) { + // Delete git refs for checkpoints in the abandoned future turns + if (orphanedCheckpointIds.length > 0) { + const git = createGitClient(input.repoPath); + await Promise.all( + orphanedCheckpointIds.map((id) => + deleteCheckpoint(git, id).catch(() => {}), + ), + ); + log.info("Deleted orphaned checkpoint refs", { + orphanedCheckpointIds, + }); + } + // Cancel the agent session — the renderer will automatically + // reconnect and resume from the truncated JSONL + await agentService.cancelSession(input.taskRunId); + log.info("Agent session cancelled for checkpoint restore", { + taskRunId: input.taskRunId, + checkpointId: input.checkpointId, + }); + } + } + } catch (err) { + // Non-fatal: git files were already reverted successfully. + // The UI will truncate its events regardless. + log.warn("Failed to truncate agent session", { + taskRunId: input.taskRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return result; + }), +}); diff --git a/apps/code/src/renderer/components/GlobalEventHandlers.tsx b/apps/code/src/renderer/components/GlobalEventHandlers.tsx index 8e8842eab..e69f7ac6a 100644 --- a/apps/code/src/renderer/components/GlobalEventHandlers.tsx +++ b/apps/code/src/renderer/components/GlobalEventHandlers.tsx @@ -9,8 +9,10 @@ import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useFocusWorkspace } from "@features/workspace/hooks/useFocusWorkspace"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { useShortcut } from "@hooks/useShortcut"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { useTRPC } from "@renderer/trpc"; +import { isMac } from "@renderer/utils/platform"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; @@ -157,33 +159,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, @@ -192,11 +204,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; diff --git a/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx b/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx index c5e973bf0..0deadccc1 100644 --- a/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx +++ b/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx @@ -1,49 +1,19 @@ -import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; +import { Keycap } from "@components/Keycap"; +import { ShortcutRecorder } from "@components/ShortcutRecorder"; +import { Tooltip } from "@components/ui/Tooltip"; +import { useShortcut } from "@hooks/useShortcut"; +import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { CATEGORY_LABELS, + type ConfigurableShortcutId, formatHotkeyParts, getShortcutsByCategory, type ShortcutCategory, } from "@renderer/constants/keyboard-shortcuts"; -import { useMemo, useState } from "react"; +import { useKeybindingsStore } from "@stores/keybindingsStore"; +import { useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -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} - - ); -} - interface KeyboardShortcutsSheetProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -65,9 +35,9 @@ export function KeyboardShortcutsSheet({ e.preventDefault()} - className="max-h-[80vh] overflow-hidden" + className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden" > - + + + ); +} + function ShortcutsHeader() { - const triggerParts = formatHotkeyParts("mod+/"); + const shortcutsKey = useShortcut("shortcuts"); + const triggerParts = formatHotkeyParts(shortcutsKey); return ( @@ -148,14 +153,30 @@ export function KeyboardShortcutsList() { key={shortcut.id} align="center" justify="between" + gap="3" px="3" - className="border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" + className="group border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" > - {shortcut.description} - + + {shortcut.description} + {shortcut.context && ( + + {shortcut.context} + + )} + +
+ {shortcut.configurable ? ( + + ) : ( + + )} +
))} @@ -168,7 +189,6 @@ export function KeyboardShortcutsList() { function SingleShortcutKeys({ keys }: { keys: string }) { const parts = formatHotkeyParts(keys); - return ( {parts.map((part) => ( @@ -185,11 +205,7 @@ function ShortcutKeys({ keys: string; alternateKeys?: string; }) { - if (!alternateKeys) { - return ; - } - - return ( + const inner = alternateKeys ? ( @@ -197,5 +213,19 @@ function ShortcutKeys({ + ) : ( + + ); + + return ( + +
+ {inner} +
+
); } diff --git a/apps/code/src/renderer/components/Keycap.tsx b/apps/code/src/renderer/components/Keycap.tsx new file mode 100644 index 000000000..e94f41919 --- /dev/null +++ b/apps/code/src/renderer/components/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/apps/code/src/renderer/components/ShortcutRecorder.tsx b/apps/code/src/renderer/components/ShortcutRecorder.tsx new file mode 100644 index 000000000..304aa4caf --- /dev/null +++ b/apps/code/src/renderer/components/ShortcutRecorder.tsx @@ -0,0 +1,335 @@ +import { Keycap } from "@components/Keycap"; +import { Tooltip } from "@components/ui/Tooltip"; +import { ContextMenu, Flex, Text } from "@radix-ui/themes"; +import { + type ConfigurableShortcutId, + DEFAULT_KEYBINDINGS, + eventToCombo, + formatHotkeyParts, + KEYBOARD_SHORTCUTS, +} from "@renderer/constants/keyboard-shortcuts"; +import { + findConflict, + MAX_CUSTOM_BINDINGS, + splitBindings, + useKeybindingsStore, +} from "@stores/keybindingsStore"; +import { useCallback, useEffect, useState } from "react"; + +function formatComboLabel(combo: string): string { + return formatHotkeyParts(combo).join("+"); +} + +interface RecordingModalProps { + commandLabel: string; + /** null = adding a new binding, string = the binding key being edited */ + editingKey: string | null; + shortcutId: ConfigurableShortcutId; + onClose: () => void; +} + +export function ShortcutRecordingModal({ + commandLabel, + editingKey, + shortcutId, + onClose, +}: RecordingModalProps) { + const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); + const updateKeybinding = useKeybindingsStore((s) => s.updateKeybinding); + const [captured, setCaptured] = useState(null); + const [conflict, setConflict] = useState(null); + + // Capture at the window level — no focus required on the input element. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + onClose(); + return; + } + + if (e.key === "Enter") { + if (captured && !conflict) { + if (editingKey) { + updateKeybinding(shortcutId, editingKey, captured); + } else { + addKeybinding(shortcutId, captured); + } + onClose(); + } + return; + } + + const combo = eventToCombo(e); + if (!combo) return; + + const result = findConflict(combo, shortcutId); + if (result.description) { + setConflict(result.description); + setCaptured(combo); + } else { + setConflict(null); + setCaptured(combo); + } + }; + + window.addEventListener("keydown", handler, { capture: true }); + return () => + window.removeEventListener("keydown", handler, { capture: true }); + }, [ + captured, + conflict, + editingKey, + shortcutId, + addKeybinding, + updateKeybinding, + onClose, + ]); + + const isAdding = editingKey === null; + const title = isAdding + ? `Add new binding for "${commandLabel}"` + : `Edit binding for "${commandLabel}"`; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss pattern — not an interactive widget +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+ + {title} + +
+ +
+ {/* Visual input — display only; actual capture happens via window listener */} +
+ {captured ? ( + formatComboLabel(captured) + ) : ( + + Press a key combination... + + )} +
+ {conflict ? ( + + Conflicts with "{conflict}" — press a different + combination + + ) : captured ? ( + + Press Enter to confirm, Escape to cancel + + ) : ( + + Press Escape to cancel + + )} +
+
+
+ ); +} + +type RecordingMode = { type: "add" } | { type: "edit"; key: string } | null; + +interface BindingChipProps { + combo: string; + commandLabel: string; + canRemove: boolean; + canAddMore: boolean; + isAtDefault: boolean; + onStartRecording: (mode: RecordingMode) => void; + onRemove: () => void; + onReset: () => void; +} + +function BindingChip({ + combo, + commandLabel, + canRemove, + canAddMore, + isAtDefault, + onStartRecording, + onRemove, + onReset, +}: BindingChipProps) { + const parts = formatHotkeyParts(combo); + + return ( + + + + + + + onStartRecording({ type: "edit", key: combo })} + > + Edit binding + + {canAddMore && ( + onStartRecording({ type: "add" })}> + Add another binding + + )} + + {canRemove ? ( + + Remove binding + + ) : ( + + + + Remove binding + + + + )} + {isAtDefault ? ( + + + + Reset to default + + + + ) : ( + + Reset to default + + )} + + + ); +} + +interface ShortcutRecorderProps { + id: ConfigurableShortcutId; + onRecordingChange?: (recording: boolean) => void; +} + +export function ShortcutRecorder({ + id, + onRecordingChange, +}: ShortcutRecorderProps) { + const [recordingMode, setRecordingMode] = useState(null); + const customs = useKeybindingsStore((s) => s.customKeybindings[id] ?? []); + const removeKeybinding = useKeybindingsStore((s) => s.removeKeybinding); + const resetShortcut = useKeybindingsStore((s) => s.resetShortcut); + const addKeybinding = useKeybindingsStore((s) => s.addKeybinding); + const hasCustom = customs.length > 0; + + const shortcutEntry = KEYBOARD_SHORTCUTS.find((s) => s.id === id); + + // Defaults are split into individual chips; customs replace them entirely. + // canAddMore counts custom bindings only — defaults don't consume the budget. + const defaultBindings = splitBindings(DEFAULT_KEYBINDINGS[id]); + const effectiveBindings = hasCustom ? customs : defaultBindings; + const canAddMore = customs.length < MAX_CUSTOM_BINDINGS; + + const startRecording = useCallback( + (mode: RecordingMode) => { + setRecordingMode(mode); + onRecordingChange?.(true); + }, + [onRecordingChange], + ); + + const stopRecording = useCallback(() => { + setRecordingMode(null); + onRecordingChange?.(false); + }, [onRecordingChange]); + + // Removing a default binding: store the remaining defaults as custom bindings. + const handleRemoveDefault = useCallback( + (key: string) => { + const remaining = defaultBindings.filter((k) => k !== key); + resetShortcut(id); + for (const k of remaining) { + addKeybinding(id, k); + } + }, + [id, defaultBindings, resetShortcut, addKeybinding], + ); + + if (!shortcutEntry) return null; + + const commandLabel = shortcutEntry.description; + const isAtDefault = !hasCustom; + + return ( + <> + {recordingMode !== null && ( + + )} + +
+ + {effectiveBindings.map((key, i) => ( + + {i > 0 && ( + + or + + )} + 1} + canAddMore={canAddMore} + isAtDefault={isAtDefault} + onStartRecording={startRecording} + onRemove={ + hasCustom + ? () => removeKeybinding(id, key) + : () => handleRemoveDefault(key) + } + onReset={() => resetShortcut(id)} + /> + + ))} + +
+ + ); +} diff --git a/apps/code/src/renderer/components/SpaceSwitcher.tsx b/apps/code/src/renderer/components/SpaceSwitcher.tsx index 2513bea11..15d98ba6d 100644 --- a/apps/code/src/renderer/components/SpaceSwitcher.tsx +++ b/apps/code/src/renderer/components/SpaceSwitcher.tsx @@ -1,5 +1,5 @@ import type { TaskData } from "@features/sidebar/hooks/useSidebarData"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { useShortcut } from "@hooks/useShortcut"; import type { Task } from "@shared/types"; 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/apps/code/src/renderer/constants/keyboard-shortcuts.ts b/apps/code/src/renderer/constants/keyboard-shortcuts.ts index b162013bb..44716a7b9 100644 --- a/apps/code/src/renderer/constants/keyboard-shortcuts.ts +++ b/apps/code/src/renderer/constants/keyboard-shortcuts.ts @@ -22,6 +22,8 @@ export const SHORTCUTS = { SPACE_UP: "mod+up", SPACE_DOWN: "mod+down", FIND_IN_CONVERSATION: "mod+f", + FILE_PICKER: "mod+p", + CHECKPOINT_TIMELINE: "mod+shift+h", BLUR: "escape", SUBMIT_BLUR: "mod+enter", } as const; @@ -35,6 +37,7 @@ export interface KeyboardShortcut { category: ShortcutCategory; context?: string; alternateKeys?: string; + configurable?: boolean; } export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ @@ -44,30 +47,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 +89,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous task", category: "navigation", alternateKeys: "ctrl+shift+tab", + configurable: true, }, { id: "next-task", @@ -88,42 +97,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 +154,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Close active tab", category: "panels", context: "Task detail", + configurable: true, }, { id: "open-in-editor", @@ -145,6 +162,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Open in external editor", category: "panels", context: "Task detail", + configurable: true, }, { id: "copy-path", @@ -152,6 +170,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", @@ -160,12 +187,29 @@ 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: "checkpoint-timeline", + keys: SHORTCUTS.CHECKPOINT_TIMELINE, + description: "Open checkpoint timeline", + 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", @@ -173,6 +217,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Previous prompt", category: "editor", context: "Message editor", + configurable: true, }, { id: "prompt-history-next", @@ -180,6 +225,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Next prompt", category: "editor", context: "Message editor", + configurable: true, }, { id: "editor-bold", @@ -218,6 +264,58 @@ 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", + "file-picker", + "checkpoint-timeline", + "paste-as-file", + "prompt-history-prev", + "prompt-history-next", +] 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, + "file-picker": SHORTCUTS.FILE_PICKER, + "checkpoint-timeline": SHORTCUTS.CHECKPOINT_TIMELINE, + "paste-as-file": SHORTCUTS.PASTE_AS_FILE, + "prompt-history-prev": "shift+up", + "prompt-history-next": "shift+down", +}; + export function getShortcutsByCategory(): Record< ShortcutCategory, KeyboardShortcut[] @@ -234,6 +332,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/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index d8451b7bd..5a509cf23 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -354,9 +354,12 @@ export function InboxSignalsTab() { return reports.filter((r) => idSet.has(r.id)); }, [reports, selectedReportIds]); - // ── Click handler: plain / cmd / shift ────────────────────────────────── + // ── Click handler: plain / cmd-or-ctrl / shift ────────────────────────── const handleReportClick = useCallback( - (reportId: string, event: { metaKey: boolean; shiftKey: boolean }) => { + ( + reportId: string, + event: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }, + ) => { // Selecting a real report clears any discovered-task selection so the // detail pane can swap to the report. useSetupStore.getState().selectDiscoveredTask(null); @@ -366,7 +369,7 @@ export function InboxSignalsTab() { reportId, reportsRef.current.map((r) => r.id), ); - } else if (event.metaKey) { + } else if (event.metaKey || event.ctrlKey) { setPendingInboxOpenMethod("click_cmd"); toggleReportSelection(reportId); } else { diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx index 8800c8701..86940f24a 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx @@ -68,7 +68,7 @@ interface ReportListPaneProps { selectedReportIds: string[]; onReportClick: ( id: string, - event: { metaKey: boolean; shiftKey: boolean }, + event: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }, ) => void; onToggleReportSelection: (id: string) => void; } diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx index 21a046282..03ebbf3f7 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx @@ -42,7 +42,11 @@ interface ReportListRowProps { report: SignalReport; isSelected: boolean; showCheckbox: boolean; - onClick: (event: { metaKey: boolean; shiftKey: boolean }) => void; + onClick: (event: { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + }) => void; onToggleChecked: () => void; index: number; /** Optional badge rendered before the standard status/priority/actionability badges. */ @@ -72,7 +76,7 @@ export function ReportListRow({ if (isInteractiveTarget(e.target)) { return; } - onClick({ metaKey: e.metaKey, shiftKey: e.shiftKey }); + onClick({ metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }); }; const rowBgClass = isSelected ? "bg-gray-3" : ""; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 9dbe099ac..52bd4c8cc 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,7 +1,13 @@ import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; +import { + eventToCombo, + formatHotkey, + tiptapEventToCombo, +} from "@renderer/constants/keyboard-shortcuts"; import { trpc } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; +import { splitBindings, useKeybindingsStore } from "@stores/keybindingsStore"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; import { queryClient } from "@utils/queryClient"; @@ -268,11 +274,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 { @@ -302,62 +309,82 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - 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 forceNavigate = event.shiftKey; - const history = historyGetter?.() ?? []; + // 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 = + tiptapCombo !== null && + splitBindings(keybindings.getKey("prompt-history-prev")).includes( + tiptapCombo, + ); + const forceNext = + tiptapCombo !== null && + splitBindings(keybindings.getKey("prompt-history-next")).includes( + tiptapCombo, + ); if ( - event.key === "ArrowUp" && - (forceNavigate || isEmpty || isAtStart) + forcePrev || + forceNext || + event.key === "ArrowUp" || + event.key === "ArrowDown" ) { - 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; - } - } - - if ( - event.key === "ArrowDown" && - (forceNavigate || 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; + 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; + } } } } @@ -479,7 +506,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(useKeybindingsStore.getState().getKey("paste-as-file"))} to paste as a file attachment instead.`, ); } diff --git a/apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts b/apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts index d0d5084d3..b84832a2d 100644 --- a/apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts +++ b/apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts @@ -1,3 +1,4 @@ +import { useShortcut } from "@hooks/useShortcut"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { useHotkeys } from "react-hotkeys-hook"; import { usePanelLayoutStore } from "../store/panelLayoutStore"; @@ -5,6 +6,7 @@ import { getLeafPanel } from "../store/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/apps/code/src/renderer/features/sessions/components/CheckpointTimelineModal.tsx b/apps/code/src/renderer/features/sessions/components/CheckpointTimelineModal.tsx new file mode 100644 index 000000000..73d5811f5 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/CheckpointTimelineModal.tsx @@ -0,0 +1,173 @@ +import { + ArrowCounterClockwise, + ClockCounterClockwise, +} from "@phosphor-icons/react"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; +import { Button, Dialog, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import type { AcpMessage } from "@shared/types/session-events"; +import { + isJsonRpcNotification, + isJsonRpcRequest, +} from "@shared/types/session-events"; +import { formatRelativeTimeShort } from "@utils/time"; +import { useMemo } from "react"; + +interface CheckpointEntry { + checkpointId: string; + timestamp: number; + userMessageSnippet: string; + turnIndex: number; +} + +function parseTimeline(events: AcpMessage[]): CheckpointEntry[] { + const entries: CheckpointEntry[] = []; + let turnIndex = 0; + let currentUserMessage = ""; + let currentCheckpointId: string | null = null; + let currentCheckpointTs = 0; + + for (const event of events) { + const msg = event.message; + + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { + if (currentCheckpointId) { + entries.push({ + checkpointId: currentCheckpointId, + timestamp: currentCheckpointTs, + userMessageSnippet: currentUserMessage, + turnIndex, + }); + } + turnIndex++; + const p = msg.params as { + prompt?: Array<{ type: string; text?: string }>; + }; + currentUserMessage = + p?.prompt + ?.filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join("") ?? ""; + currentCheckpointId = null; + currentCheckpointTs = 0; + } + + if ( + isJsonRpcNotification(msg) && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT) + ) { + const params = msg.params as { checkpointId?: string }; + if (params?.checkpointId) { + currentCheckpointId = params.checkpointId; + currentCheckpointTs = event.ts; + } + } + } + + if (currentCheckpointId) { + entries.push({ + checkpointId: currentCheckpointId, + timestamp: currentCheckpointTs, + userMessageSnippet: currentUserMessage, + turnIndex, + }); + } + + return entries.reverse(); +} + +interface CheckpointTimelineModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + events: AcpMessage[]; + onRestore: (checkpointId: string) => void; +} + +export function CheckpointTimelineModal({ + open, + onOpenChange, + events, + onRestore, +}: CheckpointTimelineModalProps) { + const entries = useMemo(() => parseTimeline(events), [events]); + + return ( + + + + + + + Checkpoint timeline + + + {entries.length === 0 ? ( + + No checkpoints in this session yet. + + ) : ( + + + {entries.map((entry) => ( + { + onOpenChange(false); + onRestore(entry.checkpointId); + }} + /> + ))} + + + )} + + + + + + + + + ); +} + +function CheckpointRow({ + entry, + onRestore, +}: { + entry: CheckpointEntry; + onRestore: () => void; +}) { + const snippet = entry.userMessageSnippet.trim().slice(0, 120); + const label = snippet || `Turn ${entry.turnIndex}`; + + return ( + + + + {label} + + + {formatRelativeTimeShort(entry.timestamp)} + + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4afb50fd6..60d74d4fd 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -1,6 +1,7 @@ import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; import { useContextUsage } from "@features/sessions/hooks/useContextUsage"; import { useConversationSearch } from "@features/sessions/hooks/useConversationSearch"; +import { useRestoreCheckpoint } from "@features/sessions/hooks/useRestoreCheckpoint"; import { SessionTaskIdProvider } from "@features/sessions/hooks/useSessionTaskId"; import { sessionStoreSetters, @@ -11,6 +12,7 @@ import { } from "@features/sessions/stores/sessionStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { SkillButtonActionMessage } from "@features/skill-buttons/components/SkillButtonActionMessage"; +import { useShortcut } from "@hooks/useShortcut"; import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; @@ -18,15 +20,18 @@ import { Box, Button, Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import type { AcpMessage } from "@shared/types/session-events"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { buildConversationItems, type ConversationItem, type TurnContext, } from "./buildConversationItems"; +import { CheckpointTimelineModal } from "./CheckpointTimelineModal"; import { ConversationSearchBar } from "./ConversationSearchBar"; import { GitActionMessage } from "./GitActionMessage"; import { GitActionResult } from "./GitActionResult"; import { mergeConversationItems } from "./mergeConversationItems"; +import { RestoreCheckpointDialog } from "./RestoreCheckpointDialog"; import { SessionFooter } from "./SessionFooter"; import { QueuedMessageView } from "./session-update/QueuedMessageView"; import { @@ -74,6 +79,7 @@ export function ConversationView({ const listRef = useRef(null); const isAtBottomRef = useRef(true); const [showScrollButton, setShowScrollButton] = useState(false); + const [timelineOpen, setTimelineOpen] = useState(false); const debugLogsCloudRuns = useSettingsStore((s) => s.debugLogsCloudRuns); const showDebugLogs = debugLogsCloudRuns; const contextUsage = useContextUsage(events); @@ -126,6 +132,17 @@ export function ConversationView({ const isCloud = session?.isCloud ?? false; + const restore = useRestoreCheckpoint({ + repoPath: repoPath ?? undefined, + taskId, + taskRunId: session?.taskRunId, + }); + + const checkpointTimelineKey = useShortcut("checkpoint-timeline"); + useHotkeys(checkpointTimelineKey, () => setTimelineOpen((o) => !o), { + preventDefault: true, + }); + const items = useMemo( () => mergeConversationItems({ @@ -209,6 +226,16 @@ export function ConversationView({ update={item.update} turnContext={item.turnContext} thoughtComplete={item.thoughtComplete} + showRestoreButton={item.turnContext.turnComplete} + onRestoreCheckpoint={ + item.turnContext.turnComplete && + item.turnContext.lastCheckpointId + ? () => + restore.requestRestore( + item.turnContext.lastCheckpointId as string, + ) + : undefined + } /> ); case "git_action_result": @@ -240,7 +267,14 @@ export function ConversationView({ ); } }, - [repoPath, taskId, slackThreadUrl, firstUserMessageId, initialItemIds], + [ + repoPath, + taskId, + slackThreadUrl, + firstUserMessageId, + initialItemIds, + restore.requestRestore, + ], ); const getItemKey = useCallback((item: ConversationItem) => item.id, []); @@ -310,6 +344,18 @@ export function ConversationView({ )} + + ); } @@ -318,10 +364,14 @@ const SessionUpdateRow = memo(function SessionUpdateRow({ update, turnContext, thoughtComplete, + showRestoreButton, + onRestoreCheckpoint, }: { update: RenderItem; turnContext: TurnContext; thoughtComplete?: boolean; + showRestoreButton?: boolean; + onRestoreCheckpoint?: () => void; }) { return ( ); }); diff --git a/apps/code/src/renderer/features/sessions/components/RestoreCheckpointDialog.tsx b/apps/code/src/renderer/features/sessions/components/RestoreCheckpointDialog.tsx new file mode 100644 index 000000000..164aa7269 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/RestoreCheckpointDialog.tsx @@ -0,0 +1,54 @@ +import { ArrowCounterClockwise, Warning } from "@phosphor-icons/react"; +import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; + +interface RestoreCheckpointDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isLoading: boolean; +} + +export function RestoreCheckpointDialog({ + open, + onOpenChange, + onConfirm, + isLoading, +}: RestoreCheckpointDialogProps) { + return ( + + + + + + + Restore checkpoint + + + + + + This will revert all file changes made after this point. This + action cannot be undone. + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index fbd0d1ee4..31d396a6b 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -28,6 +28,7 @@ export interface TurnContext { childItems: Map; turnCancelled: boolean; turnComplete: boolean; + lastCheckpointId: string | null; } export type ConversationItem = @@ -92,6 +93,7 @@ interface TurnState { context: TurnContext; gitAction: ReturnType; itemCount: number; + lastCheckpointId: string | null; } interface ItemBuilder { @@ -248,6 +250,7 @@ function handlePromptRequest( childItems, turnCancelled: false, turnComplete: false, + lastCheckpointId: null, }; b.currentTurn = { @@ -259,6 +262,7 @@ function handlePromptRequest( context, gitAction, itemCount: 0, + lastCheckpointId: null, }; b.pendingPrompts.set(msg.id, b.currentTurn); @@ -439,6 +443,15 @@ function handleNotification( }); return; } + + if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT)) { + const params = msg.params as { checkpointId?: string }; + if (params?.checkpointId && b.currentTurn) { + b.currentTurn.lastCheckpointId = params.checkpointId; + b.currentTurn.context.lastCheckpointId = params.checkpointId; + } + return; + } } function ensureProgressCardForGroup( @@ -533,6 +546,7 @@ function ensureImplicitTurn(b: ItemBuilder, ts: number) { childItems, turnCancelled: false, turnComplete: false, + lastCheckpointId: null, }; b.currentTurn = { @@ -544,6 +558,7 @@ function ensureImplicitTurn(b: ItemBuilder, ts: number) { context, gitAction: { isGitAction: false, actionType: null, prompt: "" }, itemCount: 0, + lastCheckpointId: null, }; } diff --git a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx index 0cf7d0008..dae3716ae 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx @@ -7,8 +7,8 @@ import { useSessionTaskId } from "@features/sessions/hooks/useSessionTaskId"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import type { FileItem } from "@hooks/useRepoFiles"; import { useRepoFiles } from "@hooks/useRepoFiles"; -import { Check, Copy } from "@phosphor-icons/react"; -import { Box, Code, IconButton } from "@radix-ui/themes"; +import { ArrowCounterClockwise, Check, Copy } from "@phosphor-icons/react"; +import { Box, Code, Flex, IconButton } from "@radix-ui/themes"; import { memo, useCallback, useMemo, useState } from "react"; import type { Components } from "react-markdown"; @@ -135,12 +135,17 @@ const agentComponents: Partial = { interface AgentMessageProps { content: string; + showRestoreButton?: boolean; + onRestoreCheckpoint?: () => void; } export const AgentMessage = memo(function AgentMessage({ content, + showRestoreButton, + onRestoreCheckpoint, }: AgentMessageProps) { const [copied, setCopied] = useState(false); + const canRestore = !!onRestoreCheckpoint; const handleCopy = useCallback(() => { navigator.clipboard.writeText(content); @@ -154,7 +159,10 @@ export const AgentMessage = memo(function AgentMessage({ content={content} componentsOverride={agentComponents} /> - + : } - + {showRestoreButton && ( + + + + + + )} +
); }); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx index 90eebd85b..9563e1f48 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx @@ -57,6 +57,8 @@ interface SessionUpdateViewProps { turnCancelled?: boolean; turnComplete?: boolean; thoughtComplete?: boolean; + showRestoreButton?: boolean; + onRestoreCheckpoint?: () => void; } export const SessionUpdateView = memo(function SessionUpdateView({ @@ -66,13 +68,19 @@ export const SessionUpdateView = memo(function SessionUpdateView({ turnCancelled, turnComplete, thoughtComplete, + showRestoreButton, + onRestoreCheckpoint, }: SessionUpdateViewProps) { switch (item.sessionUpdate) { case "user_message_chunk": return null; case "agent_message_chunk": return item.content.type === "text" ? ( - + ) : null; case "agent_thought_chunk": return item.content.type === "text" ? ( diff --git a/apps/code/src/renderer/features/sessions/hooks/useRestoreCheckpoint.ts b/apps/code/src/renderer/features/sessions/hooks/useRestoreCheckpoint.ts new file mode 100644 index 000000000..a3895ecaf --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useRestoreCheckpoint.ts @@ -0,0 +1,69 @@ +import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; +import { trpcClient } from "@renderer/trpc"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +interface UseRestoreCheckpointOptions { + repoPath: string | undefined; + taskId: string | undefined; + taskRunId: string | undefined; +} + +export function useRestoreCheckpoint({ + repoPath, + taskId, + taskRunId, +}: UseRestoreCheckpointOptions) { + const [dialogOpen, setDialogOpen] = useState(false); + const [pendingCheckpointId, setPendingCheckpointId] = useState( + null, + ); + const [isRestoring, setIsRestoring] = useState(false); + + const requestRestore = useCallback((checkpointId: string) => { + setPendingCheckpointId(checkpointId); + setDialogOpen(true); + }, []); + + const confirmRestore = useCallback(async () => { + if (!pendingCheckpointId || !repoPath) return; + + setIsRestoring(true); + try { + await trpcClient.checkpoint.restore.mutate({ + checkpointId: pendingCheckpointId, + repoPath, + taskRunId, + }); + if (taskId) { + sessionStoreSetters.truncateEventsToCheckpoint( + taskId, + pendingCheckpointId, + ); + } + toast.success("Checkpoint restored successfully"); + setDialogOpen(false); + setPendingCheckpointId(null); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to restore checkpoint"; + toast.error(message); + } finally { + setIsRestoring(false); + } + }, [pendingCheckpointId, repoPath, taskId, taskRunId]); + + const cancelRestore = useCallback(() => { + setDialogOpen(false); + setPendingCheckpointId(null); + }, []); + + return { + dialogOpen, + setDialogOpen, + isRestoring, + requestRestore, + confirmRestore, + cancelRestore, + }; +} diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index 718206228..da27e152d 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -5,9 +5,14 @@ import type { SessionConfigSelectOption, SessionConfigSelectOptions, } from "@agentclientprotocol/sdk"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; import type { ExecutionMode, TaskRunStatus } from "@shared/types"; import type { SkillButtonId } from "@shared/types/analytics"; -import type { AcpMessage } from "@shared/types/session-events"; +import { + type AcpMessage, + isJsonRpcNotification, + isJsonRpcRequest, +} from "@shared/types/session-events"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import type { PermissionRequest } from "../utils/parseSessionLogs"; @@ -498,6 +503,51 @@ export const sessionStoreSetters = { return useSessionStore.getState().sessions; }, + truncateEventsToCheckpoint: ( + taskId: string, + checkpointId: string, + ): boolean => { + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return false; + const session = state.sessions[taskRunId]; + if (!session) return false; + + const events = session.events; + let checkpointEventIdx = -1; + for (let i = 0; i < events.length; i++) { + const msg = events[i].message; + if (!isJsonRpcNotification(msg)) continue; + if (!isNotification(msg.method, POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT)) + continue; + const params = msg.params as { checkpointId?: string } | undefined; + if (params?.checkpointId === checkpointId) { + checkpointEventIdx = i; + break; + } + } + if (checkpointEventIdx === -1) return false; + + let cutoff = events.length; + for (let i = checkpointEventIdx + 1; i < events.length; i++) { + const msg = events[i].message; + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { + cutoff = i; + break; + } + } + + useSessionStore.setState((draft) => { + const trid = draft.taskIdIndex[taskId]; + if (!trid) return; + const s = draft.sessions[trid]; + if (s) { + s.events = s.events.slice(0, cutoff); + } + }); + return true; + }, + clearAll: () => { useSessionStore.setState((state) => { state.sessions = {}; diff --git a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx index c64480e83..ea1a639f9 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx @@ -19,6 +19,7 @@ import { Switch, Text, } from "@radix-ui/themes"; +import { formatHotkey } from "@renderer/constants/keyboard-shortcuts"; import { useTRPC } from "@renderer/trpc"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { ThemePreference } from "@stores/themeStore"; @@ -438,7 +439,9 @@ export function GeneralSettings() { Enter - ⌘ Enter + + {formatHotkey("mod+enter")} + diff --git a/apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx b/apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx index 9d71098c8..9bf1dd20d 100644 --- a/apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx +++ b/apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx @@ -1,4 +1,5 @@ import { useExternalApps } from "@features/external-apps/hooks/useExternalApps"; +import { useShortcut } from "@hooks/useShortcut"; import { CodeIcon, CopyIcon } from "@phosphor-icons/react"; import { Button, @@ -11,7 +12,7 @@ import { DropdownMenuTrigger, Kbd, } from "@posthog/quill"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { formatHotkeyParts } from "@renderer/constants/keyboard-shortcuts"; import { handleExternalAppAction } from "@utils/handleExternalAppAction"; import { ChevronDown } from "lucide-react"; import { useCallback } from "react"; @@ -62,8 +63,11 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { ); }, [targetPath]); + const openInEditorKey = useShortcut("open-in-editor"); + const copyPathKey = useShortcut("copy-path"); + useHotkeys( - SHORTCUTS.OPEN_IN_EDITOR, + openInEditorKey, (event) => { event.preventDefault(); handleOpenDefault(); @@ -73,7 +77,7 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { ); useHotkeys( - SHORTCUTS.COPY_PATH, + copyPathKey, (event) => { event.preventDefault(); handleCopyPath(); @@ -140,7 +144,9 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { {app.name} {app.id === defaultApp?.id && ( - ⌘O + {formatHotkeyParts(openInEditorKey).map((part) => ( + {part} + ))} )} @@ -150,7 +156,9 @@ export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { Copy Path - ⌘⇧C + {formatHotkeyParts(copyPathKey).map((part) => ( + {part} + ))} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx index 45f17ada1..579854855 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx @@ -19,6 +19,7 @@ import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { useBlurOnEscape } from "@hooks/useBlurOnEscape"; import { useFileWatcher } from "@hooks/useFileWatcher"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { useShortcut } from "@hooks/useShortcut"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import { useQueryClient } from "@tanstack/react-query"; @@ -66,6 +67,7 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { : effectiveRepoPath; const [filePickerOpen, setFilePickerOpen] = useState(false); + const filePickerKey = useShortcut("file-picker"); const { enableScope, disableScope } = useHotkeysContext(); @@ -76,7 +78,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/apps/code/src/renderer/hooks/useShortcut.ts b/apps/code/src/renderer/hooks/useShortcut.ts new file mode 100644 index 000000000..36a4521cf --- /dev/null +++ b/apps/code/src/renderer/hooks/useShortcut.ts @@ -0,0 +1,6 @@ +import type { ConfigurableShortcutId } from "@renderer/constants/keyboard-shortcuts"; +import { resolveKey, useKeybindingsStore } from "@stores/keybindingsStore"; + +export function useShortcut(id: ConfigurableShortcutId): string { + return useKeybindingsStore((s) => resolveKey(s.customKeybindings, id)); +} diff --git a/apps/code/src/renderer/stores/keybindingsStore.test.ts b/apps/code/src/renderer/stores/keybindingsStore.test.ts new file mode 100644 index 000000000..ad67b6c01 --- /dev/null +++ b/apps/code/src/renderer/stores/keybindingsStore.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@utils/electronStorage", () => ({ + electronStorage: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { DEFAULT_KEYBINDINGS } from "@renderer/constants/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("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 no conflict when key is unused", () => { + const result = findConflict("ctrl+z", "command-menu"); + expect(result.description).toBeNull(); + }); + + 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 + 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 + 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"] }, + }); + 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"] }, + }); + 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/apps/code/src/renderer/stores/keybindingsStore.ts b/apps/code/src/renderer/stores/keybindingsStore.ts new file mode 100644 index 000000000..cbb20ad20 --- /dev/null +++ b/apps/code/src/renderer/stores/keybindingsStore.ts @@ -0,0 +1,152 @@ +import { + CONFIGURABLE_SHORTCUT_IDS, + type ConfigurableShortcutId, + DEFAULT_KEYBINDINGS, + KEYBOARD_SHORTCUTS, +} from "@renderer/constants/keyboard-shortcuts"; +import { electronStorage } from "@utils/electronStorage"; +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; +} + +export function resolveKey( + customKeybindings: Partial>, + id: ConfigurableShortcutId, +): string { + const customs = customKeybindings[id]; + if (customs && customs.length > 0) return customs.join(","); + 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[] { + 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, +): ConflictResult { + const state = useKeybindingsStore.getState(); + + for (const id of CONFIGURABLE_SHORTCUT_IDS) { + if (id === excludeId) continue; + const keyStr = state.getKey(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 }; + } + } + + 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 { id: null, description: null, isFixed: false }; +} + +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; + if (existing.length >= MAX_CUSTOM_BINDINGS) return; + set({ + customKeybindings: { + ...get().customKeybindings, + [id]: [...existing, key], + }, + }); + }, + + 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)); + // Deduplicate — conflict detection excludes the edited shortcut's own bindings, + // so editing one binding to match another on the same shortcut can slip through. + const deduped = [...new Set(updated)]; + set({ + customKeybindings: { ...get().customKeybindings, [id]: deduped }, + }); + }, + + 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 }), + }, + ), +); 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 000000000..f9dc76f73 --- /dev/null +++ b/apps/code/tests/e2e/tests/shortcuts.spec.ts @@ -0,0 +1,456 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../fixtures/electron"; + +const isMac = process.platform === "darwin"; +const modKey = isMac ? "Meta" : "Control"; + +async function openShortcutsSheet(window: Page) { + await window.keyboard.press(`${modKey}+Slash`); + await window.getByText("Keyboard Combos").waitFor({ timeout: 5000 }); +} + +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); +} + +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 {} +} + +// 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); + 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(); + } + }); + + // ─── Configurable vs non-configurable ───────────────────────────────────── + + test("configurable rows expose clickable chip buttons", async ({ + window, + }) => { + await openShortcutsSheet(window); + + // "Open command menu" is configurable + await expect(getChips(window, "Open command menu").first()).toBeVisible(); + }); + + 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(); + await expect( + window.getByText("This shortcut cannot be customized"), + ).toBeVisible({ timeout: 2000 }); + }); + + // ─── Recording modal ────────────────────────────────────────────────────── + + test("clicking a chip opens the recording modal in edit mode", async ({ + window, + }) => { + await openShortcutsSheet(window); + + 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 ({ + window, + }) => { + await openShortcutsSheet(window); + + await getChips(window, "Open inbox").first().click(); + await window + .getByText('Edit binding for "Open inbox"') + .waitFor({ timeout: 3000 }); + + await window.keyboard.press("Escape"); + + // Modal closes, sheet stays open + await expect( + window.getByText('Edit binding for "Open inbox"'), + ).not.toBeVisible(); + await expect(window.getByText("Keyboard Combos")).toBeVisible(); + }); + + test("clicking the backdrop closes recording without saving", async ({ + window, + }) => { + await openShortcutsSheet(window); + + 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); + + await getChips(window, "Open inbox").first().click(); + await window + .getByText('Edit binding for "Open inbox"') + .waitFor({ timeout: 3000 }); + + await window.keyboard.press("k"); + + // 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(); + }); + + test("Enter without a captured combo does not close the modal", async ({ + window, + }) => { + await openShortcutsSheet(window); + + 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"); + + 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"); + + // Modal closes + await expect( + window.getByText('Add new binding for "Open inbox"'), + ).not.toBeVisible({ timeout: 3000 }); + + // The chip for the shortcut should still be visible (now showing the new binding) + await expect(getChips(window, "Open inbox").first()).toBeVisible(); + }); + + test("right-click context menu offers Edit and Add another binding", async ({ + window, + }) => { + await openShortcutsSheet(window); + + await getChips(window, "Open inbox").first().click({ button: "right" }); + + 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 + .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"); + + 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("pressing an already-used combo shows amber conflict message", async ({ + window, + }) => { + await openShortcutsSheet(window); + + 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(/Conflicts with "Toggle left sidebar"/), + ).toBeVisible({ timeout: 3000 }); + }); + + 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("resolving a conflict allows the binding to be saved", async ({ + window, + }) => { + await openShortcutsSheet(window); + + 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 + .getByText("Press Enter to confirm, Escape to cancel") + .waitFor({ timeout: 2000 }); + await window.keyboard.press("Enter"); + + await expect( + window.getByText('Add new binding for "Open command menu"'), + ).not.toBeVisible({ timeout: 3000 }); + }); + + // ─── 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("Reset to default is disabled when already at default", async ({ + window, + }) => { + await openShortcutsSheet(window); + + 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); + }); + + test("Reset to default reverts a customised shortcut", async ({ window }) => { + await openShortcutsSheet(window); + + 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 ──────────────────────────────────────────────────────────── + + 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 openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); + + 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); + + await openAddRecording(window, "Open inbox"); + await recordAndConfirm(window, "ControlOrMeta+Shift+Z"); + + await openAddRecording(window, "Open command menu"); + await recordAndConfirm(window, "ControlOrMeta+Shift+X"); + + const resetAllBtn = window.getByText("Reset all shortcuts to defaults"); + await resetAllBtn.scrollIntoViewIfNeeded(); + await resetAllBtn.click(); + + await expect(resetAllBtn).not.toBeVisible({ timeout: 3000 }); + }); +});