Skip to content

feat: support "None" option for keyboard shortcuts#86

Open
peaske7 wants to merge 7 commits intoamicalhq:mainfrom
peaske7:feat/support-none-for-shortcuts
Open

feat: support "None" option for keyboard shortcuts#86
peaske7 wants to merge 7 commits intoamicalhq:mainfrom
peaske7:feat/support-none-for-shortcuts

Conversation

@peaske7
Copy link

@peaske7 peaske7 commented Feb 1, 2026

Allow users to disable keyboard shortcuts by setting them to "None" instead of requiring a key assignment (directly addresses #83)

CleanShot 2026-02-01 at 20 09 11

Changes

  • Adds ability to clear shortcuts to "None"
  • Adds undo button to restore accidentally cleared shortcuts (persisted in localStorage)

The "undo" button is a UX flair (I noticed I was fat-fingering and clearing shortcuts to "None", so I figured having an easy way to restore was less frustrating when it happens), so I'll happily take it out if you don't think its necessary.

Summary by CodeRabbit

  • New Features

    • Replace edit icon with Clear (X) and show Undo/Restore to bring back a previously set shortcut.
    • Display a "Set shortcut…" state when none exists, with an optional Restore action.
  • Improvements

    • Previously-used shortcuts are persisted per-shortcut and can be restored.
    • Empty shortcut is now a valid explicit "disabled" state; validation and recording/clear flows updated.
  • Accessibility

    • Updated icons and ARIA attributes for clearer interaction.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Feb 1, 2026

πŸ“ Walkthrough

Walkthrough

Adds clear/restore behavior to shortcut UI, persists previous shortcuts via a new usePreviousShortcut hook (backed by useLocalStorage), treats empty shortcuts as valid (disabled), and requires a new shortcutId: string prop on ShortcutInput.

Changes

Cohort / File(s) Summary
Shortcut input & display
apps/desktop/src/components/shortcut-input.tsx
Reworked UI and control flow: early-return when value is undefined, show RecordingDisplay when recording, ShortcutDisplay with onClear when present, and NoneDisplay when absent with optional Restore. Adds shortcutId prop, activeKeysRef, handlers to save/restore previous keys, and updates subscription/validation to consult stored previous keys and clear history on commit.
Persistence hooks
apps/desktop/src/hooks/useLocalStorage.ts, apps/desktop/src/hooks/usePreviousShortcut.ts
New useLocalStorage<T>(key, initialValue) with safeStorage (get/set/remove) to handle storage errors; new usePreviousShortcut(shortcutId) to save/restore/clear previous shortcut arrays under previous-shortcut-${shortcutId} and expose previousKeys/hasPrevious.
Settings & onboarding usage
apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx, apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx
Shortcut state typed to `number[]
Validation
apps/desktop/src/utils/shortcut-validation.ts
validateShortcutComprehensive now short-circuits and returns valid for an empty candidate (length 0) so an explicit β€œdisabled” shortcut is allowed.
Imports / icons
apps/desktop/src/components/shortcut-input.tsx (imports changed)
Updated imports to include Undo2, X icons, usePreviousShortcut, useLocalStorage/safeStorage usage where applicable and reordered imports.

Sequence Diagram(s)

sequenceDiagram
  participant User as User
  participant UI as ShortcutInput (UI)
  participant Validator as validateShortcutComprehensive
  participant Hook as usePreviousShortcut
  participant LS as localStorage

  User->>UI: Click "Clear" (X)
  UI->>Hook: savePrevious(currentKeys)
  Hook->>LS: setItem("previous-shortcut-<id>", JSON)
  Note right of LS: previous keys stored

  User->>UI: Start recording / submit candidate
  UI->>Validator: validateShortcutComprehensive(candidate, prevActiveKeys)
  alt candidate is empty
    Validator-->>UI: { valid: true }
    UI->>UI: commit disabled (empty) shortcut
    UI->>Hook: clearPrevious()
    Hook->>LS: removeItem("previous-shortcut-<id>")
  else valid non-empty
    Validator-->>UI: { valid: true }
    UI->>UI: commit shortcut
    UI->>Hook: clearPrevious()
    Hook->>LS: removeItem("previous-shortcut-<id>")
  else invalid
    Validator-->>UI: { valid: false, error }
    UI-->>User: show validation error
  end

  User->>UI: Click "Undo" (Restore)
  UI->>Hook: restorePrevious()
  Hook->>LS: getItem("previous-shortcut-<id>")
  Hook-->>UI: previousKeys (and clear storage)
  UI-->>User: restored shortcut applied
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Poem

🐰 I hopped and cleared a key with glee,

Buried it safe in local memory,
Tap "Undo" and watch it spring,
A rabbit's trick β€” restore the thing! 🎩✨

πŸš₯ Pre-merge checks | βœ… 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (2 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title 'feat: support "None" option for keyboard shortcuts' clearly and specifically describes the main change: enabling users to disable shortcuts by setting them to "None" instead of requiring assignments.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • πŸ“ Generate docstrings
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

πŸ€– Fix all issues with AI agents
In `@apps/desktop/src/hooks/useLocalStorage.ts`:
- Around line 7-10: The initialization of storedValue uses JSON.parse without
error handling, which will throw if localStorage has malformed JSON; update the
initializer in useLocalStorage to wrap the localStorage.getItem(key) and
JSON.parse call in a try/catch, return initialValue on parse errors (optionally
remove the bad entry with localStorage.removeItem(key) and/or log the error),
and ensure setStoredValue remains unchanged; reference storedValue,
setStoredValue, useLocalStorage, key, and initialValue to locate and modify the
initializer.
🧹 Nitpick comments (3)
apps/desktop/src/hooks/useLocalStorage.ts (1)

12-18: Consider guarding localStorage writes against quota/access errors.

While less common, localStorage.setItem can throw (e.g., quota exceeded, private browsing mode). A silent catch would prevent runtime errors.

πŸ›‘οΈ Optional defensive fix
 useEffect(() => {
-  if (JSON.stringify(storedValue) === JSON.stringify(initialValue)) {
-    localStorage.removeItem(key);
-  } else {
-    localStorage.setItem(key, JSON.stringify(storedValue));
+  try {
+    if (JSON.stringify(storedValue) === JSON.stringify(initialValue)) {
+      localStorage.removeItem(key);
+    } else {
+      localStorage.setItem(key, JSON.stringify(storedValue));
+    }
+  } catch {
+    // Silently ignore storage errors (quota, private mode, etc.)
   }
 }, [key, storedValue, initialValue]);
apps/desktop/src/hooks/usePreviousShortcut.ts (1)

4-10: Note: Single storage key limits undo to the most recently cleared shortcut.

Using a single "previous-shortcut" key means only one shortcut can be restored at a time. If a user clears "Push to talk" then clears "Hands-free mode", only the latter can be undone. This may be intentional given the PR describes the undo button as "optional UX flair," but consider whether per-shortcut undo keys would provide a better experience.

apps/desktop/src/components/shortcut-input.tsx (1)

229-231: Variable shadowing: previousKeys shadows the hook's previousKeys.

Line 230 declares const previousKeys = activeKeys, which shadows the previousKeys from the usePreviousShortcut hook (line 192). While the current logic is correct, this naming collision is confusing and could lead to bugs during future maintenance.

♻️ Proposed rename
   onData: (keys: number[]) => {
-    const previousKeys = activeKeys;
+    const prevActiveKeys = activeKeys;
     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);

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

πŸ€– Fix all issues with AI agents
In `@apps/desktop/src/hooks/useLocalStorage.ts`:
- Around line 17-27: The hook useLocalStorage currently only initializes
storedValue once and doesn't rehydrate when key changes, causing stale in-memory
state to be written to a new key; move the initialization/rehydration into a
useEffect that runs on [key, initialValue] and inside it attempt to read
localStorage.getItem(key) and call setStoredValue(parsedValue || initialValue)
(fall back to initialValue on error) so the hook loads the correct value
whenever key or initialValue changes, while keeping the existing effect that
writes storedValue back to localStorage.
- Around line 8-13: The catch block in useLocalStorage currently calls
localStorage.removeItem(key) which can itself throw and crash; wrap that removal
in its own try/catch (or check availability) so failures during cleanup are
swallowed β€” specifically update the try/catch around localStorage.getItem in the
useLocalStorage hook and change the catch to attempt removal inside a nested
try/catch (or conditional) so removeItem failures do not propagate.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/components/shortcut-input.tsx (1)

219-243: ⚠️ Potential issue | 🟠 Major

Stale closure bug in subscription callbackβ€”activeKeys will not update.

The code comment claims the closure is "fresh on each render," but tRPC's useSubscription does not recreate callbacks on re-rendersβ€”it caches them. This means activeKeys at line 222 will always reference its initial value, breaking the key-release detection logic. The condition at line 226 will fail to trigger when keys are actually released.

The misleading comment should be removed, and the code should use a ref to access the current state:

Fix: Use ref to access current activeKeys
+ import { useRef } from "react";
+
+ // Inside ShortcutInput component
+ const activeKeysRef = useRef<number[]>([]);
+ 
+ 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 prevActiveKeys = activeKeys;
+     const prevActiveKeys = activeKeysRef.current;
      setActiveKeys(keys);
🧹 Nitpick comments (2)
apps/desktop/src/components/shortcut-input.tsx (2)

135-142: Add aria-label to icon-only button for accessibility.

The clear button uses only an icon without accessible text. Screen reader users won't know the button's purpose.

β™Ώ Proposed fix
       <Button
         variant="ghost"
         size="sm"
         className="h-6 w-6 p-0"
         onClick={onClear}
+        aria-label="Clear shortcut"
       >
         <X className="h-3 w-3" />
       </Button>

162-169: Add aria-label to the undo button.

Same accessibility concern as the clear buttonβ€”the icon-only restore button needs an accessible label.

β™Ώ Proposed fix
         <Button
           variant="ghost"
           size="sm"
           className="h-6 w-6 p-0"
           onClick={onRestore}
+          aria-label="Restore previous shortcut"
         >
           <Undo2 className="h-3 w-3" />
         </Button>

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/components/shortcut-input.tsx (1)

194-203: ⚠️ Potential issue | 🟠 Major

Reset activeKeysRef synchronously in handleStartRecording to prevent stale key validation.

activeKeysRef is only synced via a dependency effect (line 220), not synchronously. When handleStartRecording() triggers isRecordingShortcut = true, the subscription (line 225) can be enabled and fire before the reset effect (line 252) clears the state. If a user presses a combo in session A but holds a modifier, then immediately starts session B, the subscription callback can read stale keys from the previous session before they are cleared, causing the validation logic (line 232) to incorrectly finalize the old combo.

Clear both activeKeys state and activeKeysRef ref synchronously in handleStartRecording() before re-enabling recording, and also in handleCancelRecording() (which currently only resets state, not the ref).

πŸ”§ Suggested fix
 const handleStartRecording = () => {
+  setActiveKeys([]);
+  activeKeysRef.current = [];
   onRecordingShortcutChange(true);
   setRecordingStateMutation.mutate(true);
 };

 const handleCancelRecording = () => {
   onRecordingShortcutChange(false);
   setActiveKeys([]);
+  activeKeysRef.current = [];
   setRecordingStateMutation.mutate(false);
 };
πŸ€– Fix all issues with AI agents
In `@apps/desktop/src/components/shortcut-input.tsx`:
- Around line 258-260: ShortcutInput currently returns null when prop value is
undefined, causing blank UI while ShortcutsSettingsPage loads async data; change
the behavior so the UI shows a placeholder instead of nothing or ensure the
parent provides a default empty array. Either (A) update ShortcutsSettingsPage
to initialize its shortcuts state to [] (match OnboardingShortcutInput) so
ShortcutInput always receives an array, or (B) modify ShortcutInput (component
ShortcutInput in shortcut-input.tsx) to render a lightweight loading
skeleton/placeholder when value === undefined rather than returning null,
keeping existing behavior for real empty arrays.

Comment on lines +258 to +260
if (value === undefined) {
return null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all ShortcutInput call sites
rg -n --type=tsx -C2 '<ShortcutInput'

Repository: amicalhq/amical

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Find all ShortcutInput call sites - search in all files
rg -n '<ShortcutInput' -A 2 -B 2

Repository: amicalhq/amical

Length of output: 2450


🏁 Script executed:

#!/bin/bash
# First, check the ShortcutInput component definition in the target file
cat -n apps/desktop/src/components/shortcut-input.tsx | head -100

Repository: amicalhq/amical

Length of output: 3348


🏁 Script executed:

#!/bin/bash
# Check lines around 258-260
sed -n '250,270p' apps/desktop/src/components/shortcut-input.tsx | cat -n

Repository: amicalhq/amical

Length of output: 679


🏁 Script executed:

#!/bin/bash
# Check the settings file for state initialization
sed -n '1,150p' apps/desktop/src/renderer/main/pages/settings/shortcuts/index.tsx | cat -n

Repository: amicalhq/amical

Length of output: 6660


🏁 Script executed:

#!/bin/bash
# Check the onboarding component
sed -n '1,80p' apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx | cat -n

Repository: amicalhq/amical

Length of output: 2441


Components render nothing during shortcut data load.
ShortcutsSettingsPage initializes shortcuts as undefined and relies on async data loading, causing ShortcutInput to return null until the query completes. This leaves blank space in the UI during load. Consider:

  • Adding a default initial state (e.g., []) instead of undefined
  • Showing a loading skeleton or placeholder during the async phase

Note: OnboardingShortcutInput already defaults to [], so this is primarily an issue in the settings page.

πŸ€– Prompt for AI Agents
In `@apps/desktop/src/components/shortcut-input.tsx` around lines 258 - 260,
ShortcutInput currently returns null when prop value is undefined, causing blank
UI while ShortcutsSettingsPage loads async data; change the behavior so the UI
shows a placeholder instead of nothing or ensure the parent provides a default
empty array. Either (A) update ShortcutsSettingsPage to initialize its shortcuts
state to [] (match OnboardingShortcutInput) so ShortcutInput always receives an
array, or (B) modify ShortcutInput (component ShortcutInput in
shortcut-input.tsx) to render a lightweight loading skeleton/placeholder when
value === undefined rather than returning null, keeping existing behavior for
real empty arrays.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant