diff --git a/apps/desktop/src/components/shortcut-input.tsx b/apps/desktop/src/components/shortcut-input.tsx
index e537567a..74ebc598 100644
--- a/apps/desktop/src/components/shortcut-input.tsx
+++ b/apps/desktop/src/components/shortcut-input.tsx
@@ -1,9 +1,10 @@
-import React, { useState, useEffect } from "react";
+import { Undo2, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
import { Button } from "@/components/ui/button";
-import { Pencil, X } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
+import { usePreviousShortcut } from "@/hooks/usePreviousShortcut";
import { api } from "@/trpc/react";
-import { toast } from "sonner";
import { getKeyFromKeycode } from "@/utils/keycode-map";
interface ShortcutInputProps {
@@ -11,6 +12,7 @@ interface ShortcutInputProps {
onChange: (value: number[]) => void;
isRecordingShortcut?: boolean;
onRecordingShortcutChange: (recording: boolean) => void;
+ shortcutId: string;
}
const MODIFIER_KEYS = new Set([
@@ -99,6 +101,7 @@ function RecordingDisplay({
size="sm"
className="h-6 w-6 p-0"
onClick={onCancel}
+ aria-label="Cancel recording"
>
@@ -109,9 +112,11 @@ function RecordingDisplay({
function ShortcutDisplay({
value,
onEdit,
+ onClear,
}: {
value?: number[];
onEdit: () => void;
+ onClear: () => void;
}) {
// Format array as display string (e.g., ["Fn", "Space"] -> "Fn+Space")
const displayValue = value?.length
@@ -119,7 +124,7 @@ function ShortcutDisplay({
: undefined;
return (
- <>
+
{displayValue && (
-
+
- >
+
+ );
+}
+
+function NoneDisplay({
+ previousKeys,
+ onEdit,
+ onRestore,
+}: {
+ previousKeys?: number[];
+ onEdit: () => void;
+ onRestore: () => void;
+}) {
+ return (
+
+
+ {previousKeys && previousKeys.length > 0 && (
+
+ )}
+
);
}
@@ -145,11 +180,17 @@ export function ShortcutInput({
onChange,
isRecordingShortcut = false,
onRecordingShortcutChange,
+ shortcutId,
}: ShortcutInputProps) {
const [activeKeys, setActiveKeys] = useState([]);
+ const activeKeysRef = useRef([]);
+ const { previousKeys, savePrevious, clearPrevious } =
+ usePreviousShortcut(shortcutId);
const setRecordingStateMutation =
api.settings.setShortcutRecordingState.useMutation();
+ const hasShortcut = value && value.length > 0;
+
const handleStartRecording = () => {
onRecordingShortcutChange(true);
setRecordingStateMutation.mutate(true);
@@ -161,23 +202,39 @@ export function ShortcutInput({
setRecordingStateMutation.mutate(false);
};
+ const handleClearRecording = () => {
+ if (value && value.length > 0) {
+ savePrevious(value);
+ }
+ onChange([]);
+ };
+
+ const handleRestorePrevious = () => {
+ if (previousKeys.length > 0) {
+ onChange(previousKeys);
+ clearPrevious();
+ }
+ };
+
+ // Keep ref in sync with state for use in subscription callback
+ useEffect(() => {
+ activeKeysRef.current = activeKeys;
+ }, [activeKeys]);
+
// Subscribe to key events when recording
- // Note: activeKeys closure is fresh on each render because useSubscription
- // updates its callback reference, so previousKeys correctly captures the
- // previous state value when onData fires.
api.settings.activeKeysUpdates.useSubscription(undefined, {
enabled: isRecordingShortcut,
onData: (keys: number[]) => {
- const previousKeys = activeKeys;
+ const prevActiveKeys = activeKeysRef.current;
setActiveKeys(keys);
// When any key is released, validate the combination
- if (previousKeys.length > 0 && keys.length < previousKeys.length) {
- const result = validateShortcutFormat(previousKeys);
+ if (prevActiveKeys.length > 0 && keys.length < prevActiveKeys.length) {
+ const result = validateShortcutFormat(prevActiveKeys);
if (result.valid && result.shortcut) {
- // Basic format is valid - let parent handle backend validation
onChange(result.shortcut);
+ clearPrevious();
} else {
toast.error(result.error || "Invalid key combination");
}
@@ -198,6 +255,10 @@ export function ShortcutInput({
}
}, [isRecordingShortcut]);
+ if (value === undefined) {
+ return null;
+ }
+
return (
@@ -206,8 +267,18 @@ export function ShortcutInput({
activeKeys={activeKeys}
onCancel={handleCancelRecording}
/>
+ ) : hasShortcut ? (
+
) : (
-
+
)}
diff --git a/apps/desktop/src/hooks/useLocalStorage.ts b/apps/desktop/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000..47cf2b3b
--- /dev/null
+++ b/apps/desktop/src/hooks/useLocalStorage.ts
@@ -0,0 +1,50 @@
+import { useState, useEffect } from "react";
+
+// Safe localStorage utilities that never throw
+export const safeStorage = {
+ getItem(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+ },
+ setItem(key: string, value: string): boolean {
+ try {
+ localStorage.setItem(key, value);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ removeItem(key: string): boolean {
+ try {
+ localStorage.removeItem(key);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+};
+
+export function useLocalStorage(
+ key: string,
+ initialValue: T,
+): [T, React.Dispatch>] {
+ const [value, setValue] = useState(() => {
+ const item = safeStorage.getItem(key);
+ if (item === null) return initialValue;
+ try {
+ return JSON.parse(item);
+ } catch {
+ safeStorage.removeItem(key);
+ return initialValue;
+ }
+ });
+
+ useEffect(() => {
+ safeStorage.setItem(key, JSON.stringify(value));
+ }, [key, value]);
+
+ return [value, setValue];
+}
diff --git a/apps/desktop/src/hooks/usePreviousShortcut.ts b/apps/desktop/src/hooks/usePreviousShortcut.ts
new file mode 100644
index 00000000..e422269d
--- /dev/null
+++ b/apps/desktop/src/hooks/usePreviousShortcut.ts
@@ -0,0 +1,37 @@
+import { useCallback } from "react";
+import { useLocalStorage } from "./useLocalStorage";
+
+export function usePreviousShortcut(shortcutId: string) {
+ const storageKey = `previous-shortcut-${shortcutId}`;
+ const [previousKeys, setPreviousKeys] = useLocalStorage(
+ storageKey,
+ [],
+ );
+
+ const savePrevious = useCallback(
+ (keys: number[]) => {
+ if (keys.length > 0) {
+ setPreviousKeys(keys);
+ }
+ },
+ [setPreviousKeys],
+ );
+
+ const restorePrevious = useCallback(() => {
+ const keys = previousKeys;
+ setPreviousKeys([]);
+ return keys;
+ }, [previousKeys, setPreviousKeys]);
+
+ const clearPrevious = useCallback(() => {
+ setPreviousKeys([]);
+ }, [setPreviousKeys]);
+
+ return {
+ previousKeys,
+ savePrevious,
+ restorePrevious,
+ clearPrevious,
+ hasPrevious: previousKeys.length > 0,
+ };
+}
diff --git a/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx b/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx
index 4be71fe4..ebe1d7bf 100644
--- a/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx
+++ b/apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx
@@ -7,12 +7,14 @@ import { api } from "@/trpc/react";
import { toast } from "sonner";
export function ShortcutsSettingsPage() {
- const [pushToTalkShortcut, setPushToTalkShortcut] = useState([]);
+ const [pushToTalkShortcut, setPushToTalkShortcut] = useState<
+ number[] | undefined
+ >();
const [toggleRecordingShortcut, setToggleRecordingShortcut] = useState<
- number[]
- >([]);
+ number[] | undefined
+ >();
const [pasteLastTranscriptShortcut, setPasteLastTranscriptShortcut] =
- useState([]);
+ useState();
const [recordingShortcut, setRecordingShortcut] = useState<
"pushToTalk" | "toggleRecording" | "pasteLastTranscript" | null
>(null);
@@ -113,6 +115,7 @@ export function ShortcutsSettingsPage() {
onRecordingShortcutChange={(recording) =>
setRecordingShortcut(recording ? "pushToTalk" : null)
}
+ shortcutId="pushToTalk"
/>
@@ -140,6 +143,7 @@ export function ShortcutsSettingsPage() {
onRecordingShortcutChange={(recording) =>
setRecordingShortcut(recording ? "toggleRecording" : null)
}
+ shortcutId="toggleRecording"
/>
@@ -168,6 +172,7 @@ export function ShortcutsSettingsPage() {
recording ? "pasteLastTranscript" : null,
)
}
+ shortcutId="pasteLastTranscript"
/>
diff --git a/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx b/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx
index f70e9d08..1e05b8ec 100644
--- a/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx
+++ b/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx
@@ -56,6 +56,7 @@ export function OnboardingShortcutInput() {
onChange={handleShortcutChange}
isRecordingShortcut={isRecording}
onRecordingShortcutChange={setIsRecording}
+ shortcutId="pushToTalk"
/>
diff --git a/apps/desktop/src/utils/shortcut-validation.ts b/apps/desktop/src/utils/shortcut-validation.ts
index be36e504..d59b9010 100644
--- a/apps/desktop/src/utils/shortcut-validation.ts
+++ b/apps/desktop/src/utils/shortcut-validation.ts
@@ -220,6 +220,11 @@ export function validateShortcutComprehensive(
const { candidateShortcut, candidateType, shortcutsByType, platform } =
context;
+ // Allow empty shortcut (user intentionally disabling)
+ if (candidateShortcut.length === 0) {
+ return { valid: true };
+ }
+
const otherShortcuts = Object.entries(shortcutsByType)
.filter(([shortcutType]) => shortcutType !== candidateType)
.map(([, shortcutKeys]) => shortcutKeys);