feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed

mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.
This commit is contained in:
Brooklyn Nicholson
2026-06-07 21:23:14 -05:00
parent c88e7342ba
commit 49579f355c
3 changed files with 44 additions and 12 deletions
+11 -2
View File
@@ -2,11 +2,14 @@ import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
CHAT_SIDEBAR_PANE_ID,
FILE_BROWSER_PANE_ID,
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
@@ -109,8 +112,14 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
'view.toggleSidebar': toggleSidebarOpen,
'view.toggleRightSidebar': toggleFileBrowserOpen,
'view.toggleSidebar': () => {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
toggleSidebarOpen()
},
'view.toggleRightSidebar': () => {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
toggleFileBrowserOpen()
},
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,
@@ -1,4 +1,4 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PaneMain, PaneShell } from './pane-shell'
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
@@ -80,6 +80,10 @@ const HOVER_INTENT_SENSITIVITY = 5 // px; below this between polls === settled
const HOVER_REVEAL_SLIDE_MS = 260 // panel slide-in duration; inert until elapsed
const HOVER_REVEAL_GRACE = 24 // px slop around the panel before a revealed pane closes
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
@@ -234,6 +238,15 @@ export function Pane({
// then so the cursor never flips or lands on a row before it's in view.
const [interactive, setInteractive] = useState(false)
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
const revealed = overlayActive && hoverRevealed
const stopPoll = useCallback(() => {
if (pollId.current !== null) {
clearTimeout(pollId.current)
@@ -301,6 +314,25 @@ export function Pane({
useEffect(() => stopPoll, [stopPoll])
// Keyboard toggle (mod+b / mod+j) routes here when the track is force-collapsed
// (narrow window) so the shortcut still does something — it flips the reveal.
useEffect(() => {
if (typeof window === 'undefined' || !overlayActive) {
return
}
const onToggle = (e: Event) => {
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
stopPoll()
setHoverRevealed(v => !v)
}
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
}, [id, overlayActive, stopPoll])
// While revealed, drive close off cursor geometry rather than pointer-events
// bookkeeping: a panel that slid in under a still cursor never fires
// pointerenter/leave, so listen on the document and close once the cursor
@@ -356,20 +388,11 @@ export function Pane({
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const canResize = open && resizable
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
const revealed = overlayActive && hoverRevealed
// Reset stale reveal state when the track reopens/disables, and surface the
// effective state so consumers can render full content while floated.
useEffect(() => {