Skip to content

Migrate web keyboard shortcuts to TanStack hotkeys manager#69

Open
juliusmarminge wants to merge 1 commit intomainfrom
codething/6941868b
Open

Migrate web keyboard shortcuts to TanStack hotkeys manager#69
juliusmarminge wants to merge 1 commit intomainfrom
codething/6941868b

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Feb 18, 2026

Summary

  • Replace ad-hoc window/document keyboard listeners with @tanstack/react-hotkeys registrations in ChatView, Sidebar, and context-menu fallback flows.
  • Add keybinding utilities to convert configured shortcuts into TanStack RawHotkey entries and deduplicate command hotkeys while preserving config order.
  • Keep existing shortcut behavior (terminal actions, new chat/new local chat, open favorite editor, Escape-to-close interactions) while centralizing registration/unregistration.
  • Add @tanstack/react-hotkeys dependency and lockfile updates.
  • Expand keybinding unit coverage for hotkey normalization and command hotkey extraction.

Testing

  • apps/web/src/keybindings.test.ts: added checks for shortcutToRawHotkey alias normalization and shortcutsForCommands dedupe/order behavior.
  • Lint: Not run.
  • Full test suite: Not run.

Open with Devin

Note

Medium Risk
Changes global keyboard shortcut registration for terminal/chat/editor actions and Escape handling, which can subtly break or conflict with existing shortcuts across focus contexts. Scope is limited to the web UI and includes added unit tests to validate hotkey normalization/deduping.

Overview
Migrates web keyboard shortcut handling in ChatView, Sidebar, and contextMenuFallback from imperative window/document keydown listeners to @tanstack/react-hotkeys (useHotkey/getHotkeyManager().register) with explicit unregister cleanup.

Adds keybinding helpers in keybindings.ts (shortcutToRawHotkey, shortcutsForCommands) to convert configured shortcuts into TanStack RawHotkeys and dedupe them in config order, and expands keybindings.test.ts coverage for these helpers. Updates apps/web dependencies/lockfile to include @tanstack/react-hotkeys.

Written by Cursor Bugbot for commit db5681e. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

Release Notes

  • Refactor

    • Centralized keyboard event handling for terminal, chat, editor, and context menu interactions through a unified hotkey management system.
  • Tests

    • Added tests for keyboard binding transformation and command mapping utilities.

- replace window keydown listeners with `@tanstack/react-hotkeys` registrations in chat, sidebar, and context menu flows
- add `shortcutToRawHotkey` and `shortcutsForCommands` helpers for normalized, deduplicated hotkey mappings
- add focused keybinding tests and lockfile/package updates for new hotkeys deps
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 18, 2026

Walkthrough

The changes introduce a centralized hotkey management system by integrating the @tanstack/react-hotkeys library. Direct keyboard event listeners across multiple components are replaced with hotkey manager-based registration for terminal actions, chat shortcuts, editor opening, and context menu dismissal.

Changes

Cohort / File(s) Summary
Dependency Addition
apps/web/package.json
Added new dependency @tanstack/react-hotkeys@^0.1.0 to support centralized hotkey management.
Hotkey Manager Integration
apps/web/src/components/ChatView.tsx, apps/web/src/components/Sidebar.tsx, apps/web/src/contextMenuFallback.ts
Migrated from direct window/document keydown event listeners to hotkey manager-based registration. Each component now registers hotkeys for specific actions (terminal toggle/split/close/new, chat creation, context menu escape) with proper cleanup on unmount. Uses derived hotkeys from keybindings configuration.
Keybindings Utilities
apps/web/src/keybindings.ts
Added public utilities shortcutToRawHotkey() and shortcutsForCommands() to convert KeybindingShortcut objects to RawHotkey format compatible with hotkey manager. Includes helper function shortcutSignature() for deduplication.
Keybindings Tests
apps/web/src/keybindings.test.ts
Added test coverage for new utility functions verifying correct conversion of shortcuts to raw hotkeys and deduplication of hotkeys for command batches.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% 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 pull request title 'Migrate web keyboard shortcuts to TanStack hotkeys manager' directly and clearly describes the main change: migrating from ad-hoc keyboard listeners to a centralized hotkey management system using TanStack's library.

✏️ 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
  • Commit unit tests in branch codething/6941868b

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

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Feb 18, 2026

Migrate web keyboard shortcuts to TanStack hotkeys and replace global window listeners in ChatView, Sidebar, and contextMenuFallback

Replace manual keydown handlers with @tanstack/react-hotkeys registrations, add shortcutsForCommands and shortcutToRawHotkey helpers in keybindings.ts, and update ChatView and Sidebar to use getHotkeyManager with proper unregister cleanup.

📍Where to Start

Start with the hotkey conversion and filtering utilities in keybindings.ts, then review their use in ChatView in ChatView.tsx.


Macroscope summarized db5681e.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Feb 18, 2026

Greptile Summary

Migrates keyboard shortcut handling in the web app from ad-hoc window/document keydown listeners to centralized @tanstack/react-hotkeys registrations. Adds utility functions (shortcutToRawHotkey, shortcutsForCommands) to convert the existing keybinding config into TanStack RawHotkey entries with deduplication, and updates ChatView, Sidebar, and contextMenuFallback to use the new hotkey manager.

  • Terminal shortcuts (toggle, split, close, new) in ChatView now register via getHotkeyManager().register() with a shared handler per unique key combo.
  • Chat shortcuts (new, newLocal) in Sidebar follow the same shared-handler pattern.
  • Escape-to-close for expanded images in ChatView uses the useHotkey React hook.
  • Context menu fallback Escape handler migrated from document.addEventListener to getHotkeyManager().register() with a cleanedUp guard against double-cleanup.
  • New unit tests cover shortcutToRawHotkey normalization and shortcutsForCommands deduplication/ordering.
  • The migration preserves existing shortcut behavior while centralizing registration/unregistration through TanStack's hotkey manager. The shared-handler pattern (one handler checking multiple is*Shortcut functions) works correctly but introduces redundant checks that could be simplified in a follow-up.

Confidence Score: 4/5

  • This PR is safe to merge — behavior is preserved and no functional regressions were identified.
  • The migration is mechanically sound: all previous keyboard shortcuts are re-registered through the TanStack hotkey manager with equivalent behavior. The shared-handler pattern introduces some redundant checking but no correctness issues. New utility functions are well-tested. The only concern is the style-level redundancy that could accumulate as more shortcuts are added. Score of 4 reflects high confidence with minor style suggestions.
  • apps/web/src/components/ChatView.tsx and apps/web/src/components/Sidebar.tsx use a shared-handler pattern that could benefit from per-command registration for clarity.

Important Files Changed

Filename Overview
apps/web/src/keybindings.ts Adds shortcutToRawHotkey and shortcutsForCommands utilities with proper deduplication via shortcutSignature. Clean, well-tested additions.
apps/web/src/keybindings.test.ts Adds unit tests for shortcutToRawHotkey normalization and shortcutsForCommands deduplication/ordering. Good coverage of new utility functions.
apps/web/src/contextMenuFallback.ts Replaces manual document.addEventListener("keydown") with getHotkeyManager().register("Escape"). Adds cleanedUp guard to prevent double-cleanup. Correct implementation.
apps/web/src/components/Sidebar.tsx Migrates chat.new/chat.newLocal shortcuts to TanStack hotkeys. Registers all chat hotkeys with a shared handler. Functionally correct but uses a redundant double-checking pattern.
apps/web/src/components/ChatView.tsx Migrates terminal shortcuts and Escape-to-close to TanStack hotkeys. Terminal handler uses shared-handler pattern with redundant is*Shortcut re-checks inside TanStack callbacks.
apps/web/package.json Adds @tanstack/react-hotkeys ^0.1.0 dependency. Consistent with existing TanStack usage in the project.
bun.lock Lock file updated for new @tanstack/react-hotkeys and transitive @tanstack/hotkeys dependencies.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["keybindings config<br/>(ResolvedKeybindingsConfig)"] --> B["shortcutsForCommands()"]
    B --> C["RawHotkey[] (deduplicated)"]
    C --> D["getHotkeyManager().register()"]
    
    D --> E["ChatView: terminal hotkeys<br/>(toggle, split, close, new)"]
    D --> F["ChatView: openFavoriteEditor hotkeys"]
    D --> G["Sidebar: chat hotkeys<br/>(new, newLocal)"]
    
    A --> H["shortcutToRawHotkey()"]
    H --> I["normalizeKeyName() + modifier flags"]
    I --> C

    J["useHotkey('Escape')"] --> K["ChatView: close expanded image"]
    D --> L["contextMenuFallback: Escape dismiss"]
    
    E --> M["is*Shortcut() re-check<br/>+ when-clause evaluation"]
    F --> M
    G --> M
    M --> N["Action dispatch"]
Loading

Last reviewed commit: db5681e

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

7 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +799 to +858
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

const terminalCommands = [
  { commands: ["terminal.toggle"] as const, action: () => toggleTerminalVisibility() },
  { commands: ["terminal.split"] as const, action: () => { /* ... */ } },
  // ...
] as const;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +394 to +432
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 2131 to 2157
useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown

@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.

🧹 Nitpick comments (1)
apps/web/src/components/Sidebar.tsx (1)

394-432: Correct but consider optimizing with refs for frequently-changing state.

The effect correctly re-registers hotkeys when dependencies change, ensuring fresh state values. However, accessing state.threads, state.projects, and state.activeThreadId directly in the handler causes re-registration on every state change.

If performance becomes a concern, consider using refs for state values that the handler reads but shouldn't trigger re-registration:

♻️ Optional optimization pattern
+  const stateRef = useRef({ threads: state.threads, projects: state.projects, activeThreadId: state.activeThreadId });
+  useEffect(() => {
+    stateRef.current = { threads: state.threads, projects: state.projects, activeThreadId: state.activeThreadId };
+  }, [state.threads, state.projects, state.activeThreadId]);

   useEffect(() => {
     const manager = getHotkeyManager();
     const handles = chatHotkeys.map((hotkey) =>
       manager.register(
         hotkey,
         (event) => {
-          const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
+          const { threads, projects, activeThreadId } = stateRef.current;
+          const activeThread = threads.find((t) => t.id === activeThreadId);
           // ... rest of handler
         },
         // options
       ),
     );
     return () => { /* cleanup */ };
-  }, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
+  }, [chatHotkeys, handleNewThread, keybindings]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 394 - 432, The effect
re-registers hotkeys on any change to state.threads, state.projects, or
state.activeThreadId; to prevent excessive re-registration, create refs (e.g.,
threadsRef, projectsRef, activeThreadIdRef) that you keep updated from state
(via small useEffect(s) or immediately before registering) and read those refs
inside the hotkey handler instead of reading state directly; leave only
chatHotkeys, handleNewThread, and keybindings in the registration effect's
dependency array so handlers use up-to-date values from refs while avoiding
re-registering on frequent state updates; keep the existing getHotkeyManager
registration and cleanup (handle.unregister) logic intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/src/components/Sidebar.tsx`:
- Around line 394-432: The effect re-registers hotkeys on any change to
state.threads, state.projects, or state.activeThreadId; to prevent excessive
re-registration, create refs (e.g., threadsRef, projectsRef, activeThreadIdRef)
that you keep updated from state (via small useEffect(s) or immediately before
registering) and read those refs inside the hotkey handler instead of reading
state directly; leave only chatHotkeys, handleNewThread, and keybindings in the
registration effect's dependency array so handlers use up-to-date values from
refs while avoiding re-registering on frequent state updates; keep the existing
getHotkeyManager registration and cleanup (handle.unregister) logic intact.

@github-actions github-actions bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant