* fix: prevent Shift+Tab from changing a backgrounded agent's mode The New Workspace mode-cycle shortcut (Shift+Tab) could reach a still-mounted background agent's mode control and silently change that running agent's execution mode — including into a permissive/bypass mode — with no feedback in the New Workspace UI. Root cause is in useKeyboardActionHandler: it re-registered its handler on every render (a fresh inline `actions` array plus a changing `handle` identity). The dispatcher runs the most-recently-registered matching handler first, so a frequently re-rendering control climbed ahead of the active one and consumed the key. The registered `handle`/`enabled` were also captured per registration and read stale by the native keydown listener. Fix: register once per (handlerId, priority, actions); read `handle` and `isActive` live from a ref; fold `enabled` into a fresh `isActive` so the dispatcher re-checks it at dispatch time. Registration order stays stable and callbacks stay current for all six consumers of the hook. Add an e2e regression test that opens a live agent, opens New Workspace, and presses Shift+Tab. It asserts the running agent's mode is unchanged: its daemon-committed mode, plus no set_agent_mode_request on the wire. It fails on the previous code (the mode was flipped to a more permissive mode) and passes with this change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): restore workspace pin shortcut build The shortcut change landed with an import path removed by the workspace tabs reshape, breaking app typecheck and web builds. Import the canonical workspace tab model. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
63 lines
2.7 KiB
TypeScript
63 lines
2.7 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
|
|
import {
|
|
keyboardActionDispatcher,
|
|
type KeyboardActionDefinition,
|
|
type KeyboardActionId,
|
|
} from "@/keyboard/keyboard-action-dispatcher";
|
|
|
|
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 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),
|
|
});
|
|
}, [input.handlerId, input.priority, actionsKey]);
|
|
}
|