fix(ui): refine shortcut and recent session interactions

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent aba10476c6
commit 6420460dfc
25 changed files with 545 additions and 133 deletions
@@ -18,7 +18,7 @@ Component interaction keys that are not application commands, such as list navig
- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`.
- `schema.ts` derives action and category types and provides schema lookup and effective binding resolution.
- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules.
- `registry.ts` owns the active handler for each action ID.
- `registry.ts` owns the active handler for each action ID and stack-safe temporary suspension of all application handlers.
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render.
- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts.
@@ -35,7 +35,11 @@ The settings recorder also stops at two chords. It keeps the recording local unt
# Dispatching
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed.
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; unconsumed keys retain local input behavior, while consumed keys are prevented and stopped. Normal application shortcuts remain window-bubble listeners.
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending dispatcher prefix, so stale second keys and Escape cannot consume it.
Shared `DropdownMenu` can opt into this boundary with `disableGlobalShortcuts`; it suspends while open for both controlled and uncontrolled menus and resumes on close or unmount.
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
@@ -134,6 +134,35 @@ describe('ShortcutDispatcher', () => {
expect(calls).toEqual(['x', 'y']);
});
test('invalidates a prefix when shortcut suspension changes', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
expect(dispatcher.dispatch(key('g'))).toBe(true);
const resume = registry.suspend();
expect(dispatcher.hasActivePrefix()).toBe(false);
expect(dispatcher.handleEscape()).toBe(false);
resume();
expect(dispatcher.dispatch(key('h'))).toBe(false);
expect(calls).toEqual([]);
});
test('marks a second key dispatched from capture so bubble does not dispatch it again', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
const secondKey = key('h');
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false);
expect(calls).toEqual(['sequence']);
});
test('stops after the first handler that accepts a conflicting binding', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
+30 -5
View File
@@ -29,6 +29,8 @@ export class ShortcutDispatcher {
private readonly timeoutMs: number;
private prefix: string | undefined;
private expiresAt = 0;
private prefixSuspensionVersion = 0;
private readonly capturedPrefixEvents = new WeakSet<KeyboardEvent>();
constructor(private readonly options: ShortcutDispatcherOptions) {
this.now = options.now ?? Date.now;
@@ -39,12 +41,10 @@ export class ShortcutDispatcher {
if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) {
return false;
}
if (event.key === 'Escape' && this.prefix) {
if (event.key === 'Escape' && this.hasActivePrefix()) {
return this.handleEscape();
}
if (this.prefix && this.now() >= this.expiresAt) {
this.clear();
}
this.hasActivePrefix();
const matches = this.getMatches();
if (this.prefix) {
@@ -73,6 +73,7 @@ export class ShortcutDispatcher {
if (leader) {
this.prefix = leader.chords[0];
this.expiresAt = this.now() + this.timeoutMs;
this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion();
return true;
}
return false;
@@ -81,6 +82,7 @@ export class ShortcutDispatcher {
clear(): void {
this.prefix = undefined;
this.expiresAt = 0;
this.prefixSuspensionVersion = 0;
}
handleBlur(): void {
@@ -88,11 +90,34 @@ export class ShortcutDispatcher {
}
handleEscape(): boolean {
const hadPrefix = Boolean(this.prefix);
const hadPrefix = this.hasActivePrefix();
this.clear();
return hadPrefix;
}
hasActivePrefix(): boolean {
if (!this.prefix) return false;
if (
this.now() >= this.expiresAt
|| this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion()
) {
this.clear();
return false;
}
return true;
}
dispatchActivePrefix(event: KeyboardEvent): boolean {
this.capturedPrefixEvents.add(event);
return this.dispatch(event);
}
consumeCapturedPrefixEvent(event: KeyboardEvent): boolean {
if (!this.capturedPrefixEvents.has(event)) return false;
this.capturedPrefixEvents.delete(event);
return true;
}
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
for (const match of matches) {
if (match.handler(event) !== false) {
@@ -25,3 +25,20 @@ test('a later registration takes over after the first unregisters', () => {
first();
expect(registry.get('open_settings')).toBe(secondHandler);
});
test('suspends all handlers until every idempotent cleanup completes', () => {
const registry = new ShortcutRegistry();
const handler = () => undefined;
registry.register('open_settings', handler);
const resumeFirst = registry.suspend();
const resumeSecond = registry.suspend();
expect(registry.get('open_settings')).toBe(undefined);
resumeFirst();
resumeFirst();
expect(registry.get('open_settings')).toBe(undefined);
resumeSecond();
resumeSecond();
expect(registry.get('open_settings')).toBe(handler);
});
+22
View File
@@ -9,6 +9,8 @@ interface RegisteredHandler {
/** Active application command handlers, keyed by shortcut action ID. */
export class ShortcutRegistry {
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
private suspensionCount = 0;
private suspensionVersion = 0;
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
const registration = { handler };
@@ -28,9 +30,29 @@ export class ShortcutRegistry {
}
get(actionId: ShortcutActionId): ShortcutHandler | undefined {
if (this.suspensionCount > 0) return undefined;
return this.handlers.get(actionId)?.[0]?.handler;
}
/** Temporarily disables every registered application shortcut. */
suspend(): () => void {
this.suspensionCount += 1;
this.suspensionVersion += 1;
let active = true;
return () => {
if (!active) return;
active = false;
this.suspensionCount -= 1;
if (this.suspensionCount === 0) {
this.suspensionVersion += 1;
}
};
}
getSuspensionVersion(): number {
return this.suspensionVersion;
}
actionIds(): IterableIterator<ShortcutActionId> {
return this.handlers.keys();
}