Files
paseo/packages/app/src/hooks/use-keyboard-action-handler.ts
T
Mohamed Boudra 60bafad3ee feat(app): make the New tab launcher keyboard-first
Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands.
2026-08-17 21:15:16 +02:00

64 lines
2.8 KiB
TypeScript

import { useEffect, useRef } from "react";
import type {
KeyboardActionDefinition,
KeyboardActionId,
} from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
interface UseKeyboardActionHandlerInput {
handlerId: string;
actions: readonly KeyboardActionId[];
enabled: boolean;
priority: number;
isActive?: () => boolean;
handle: (action: KeyboardActionDefinition) => boolean;
}
/**
* Registers a keyboard action handler with the global dispatcher.
*
* The dispatcher is driven by a native window keydown listener and calls the
* most-recently-registered matching handler first (see keyboard-action-dispatcher.ts).
* Two properties must hold or handlers bound to the same action step on each other:
*
* 1. Stable registration order. registerHandler bumps a registration counter, so
* re-registering on every render reshuffles which handler the dispatcher reaches
* first. A frequently re-rendering control (e.g. a running agent's mode control)
* would then jump ahead of another (e.g. the New Workspace draft) and consume the
* key, silently acting on the wrong surface. So we register once per
* (handlerId, priority, actions) and never re-register just because handle,
* isActive, or enabled changed.
*
* 2. Fresh callbacks. Because we do not re-register on every render, the registered
* entry must read the latest props at dispatch time. handle and isActive are read
* live from a ref, and enabled is folded into isActive so the dispatcher
* re-evaluates it fresh (the plain enabled field it filters on is captured at
* registration and would otherwise go stale).
*/
export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const inputRef = useRef(input);
inputRef.current = input;
// Only these identity-affecting fields trigger a re-register. actions is compared by
// content, not array identity, so inline literals do not churn the registration.
const actionsKey = input.actions.join(" ");
useEffect(() => {
return keyboardActionDispatcher.registerHandler({
handlerId: inputRef.current.handlerId,
actions: inputRef.current.actions,
// Always-on at the coarse filter; the real enable/active gate is re-checked fresh
// inside isActive below so it can never be stale in the registry entry.
enabled: true,
priority: inputRef.current.priority,
isActive: () => {
const current = inputRef.current;
if (!current.enabled) return false;
return current.isActive ? current.isActive() : true;
},
handle: (action) => inputRef.current.handle(action),
});
}, [actionsKey, input.handlerId, input.priority, keyboardActionDispatcher]);
}