feat: add UI hotkey wiring and Cmd/Ctrl+B sidebar toggle (#1514)

* feat: add UI hotkey wiring and Cmd/Ctrl+B sidebar toggle

Add a reusable useHotkey hook for registering global keyboard
shortcuts (with 'mod' resolving to Cmd on macOS, Ctrl elsewhere),
and wire up the first shortcut: mod+b toggles the left sidebar.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: ignore key repeat for sidebar toggle hotkey

Holding Cmd/Ctrl+B fired repeated keydown events, toggling the sidebar
multiple times. Add an ignoreRepeat option to useHotkey and enable it for
the sidebar toggle so one held press toggles once.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: Brendan Whiting <16016903+bwhiting2356@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
Brendan Whiting 2026-06-12 10:53:28 -07:00 • committed by GitHub
parent 258b5b4034
commit 7dac89a3b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 126 additions and 0 deletions

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { SidebarSimpleIcon } from "@phosphor-icons/react";
import { useHotkey } from "@/lib/hotkeys";
import { cn } from "@/lib/utils";
const STORAGE_WIDTH = "open-swe.sidebar.width";
@ -43,6 +44,8 @@ export function useSidebarLayout() {
const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
useHotkey("mod+b", toggle, { enableInFormFields: true, ignoreRepeat: true });
const closeOnMobile = useCallback(() => {
if (typeof window === "undefined") return;
// State-only: don't persist, so the desktop collapsed preference is preserved.

123
ui/src/lib/hotkeys.ts Normal file
View file

@ -0,0 +1,123 @@
import { useEffect, useRef } from "react"
export interface HotkeyOptions {
enabled?: boolean
preventDefault?: boolean
enableInFormFields?: boolean
ignoreRepeat?: boolean
}
interface ParsedCombo {
key: string
mod: boolean
meta: boolean
ctrl: boolean
alt: boolean
shift: boolean
}
function isMac(): boolean {
if (typeof navigator === "undefined") return false
return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent)
}
/** Parse a combo string like "mod+b" or "shift+escape" into its parts. */
function parseCombo(combo: string): ParsedCombo {
const parsed: ParsedCombo = {
key: "",
mod: false,
meta: false,
ctrl: false,
alt: false,
shift: false,
}
for (const part of combo
.toLowerCase()
.split("+")
.map((p) => p.trim())) {
switch (part) {
case "":
break
case "mod":
parsed.mod = true
break
case "meta":
case "cmd":
case "command":
parsed.meta = true
break
case "ctrl":
case "control":
parsed.ctrl = true
break
case "alt":
case "option":
parsed.alt = true
break
case "shift":
parsed.shift = true
break
default:
parsed.key = part
}
}
return parsed
}
function eventMatchesCombo(event: KeyboardEvent, combo: ParsedCombo): boolean {
if (event.key.toLowerCase() !== combo.key) return false
const mac = isMac()
const expectMeta = combo.meta || (combo.mod && mac)
const expectCtrl = combo.ctrl || (combo.mod && !mac)
return (
event.metaKey === expectMeta &&
event.ctrlKey === expectCtrl &&
event.altKey === combo.alt &&
event.shiftKey === combo.shift
)
}
function isFormField(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
)
}
/**
* Register a global keyboard shortcut. Use "mod" for the platform meta key
* (Cmd on macOS, Ctrl elsewhere). Accepts one combo or several aliases.
*/
export function useHotkey(
combo: string | string[],
handler: (event: KeyboardEvent) => void,
options: HotkeyOptions = {}
) {
const {
enabled = true,
preventDefault = true,
enableInFormFields = false,
ignoreRepeat = false,
} = options
const handlerRef = useRef(handler)
handlerRef.current = handler
const comboKey = Array.isArray(combo) ? combo.join(",") : combo
useEffect(() => {
if (!enabled || typeof window === "undefined") return
const combos = comboKey.split(",").map(parseCombo)
const onKeyDown = (event: KeyboardEvent) => {
if (ignoreRepeat && event.repeat) return
if (!enableInFormFields && isFormField(event.target)) return
if (!combos.some((c) => eventMatchesCombo(event, c))) return
if (preventDefault) event.preventDefault()
handlerRef.current(event)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [enabled, preventDefault, enableInFormFields, ignoreRepeat, comboKey])
}