Tighten Escape agent interrupt shortcut

This commit is contained in:
Mohamed Boudra
2026-04-22 12:52:47 +07:00
parent 6edf19c7c6
commit fc86ce2a5f
9 changed files with 175 additions and 30 deletions
+62 -1
View File
@@ -9,6 +9,8 @@ import type { GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer"; import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments"; import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
const { const {
theme, theme,
imageMetadata, imageMetadata,
@@ -325,7 +327,9 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
})); }));
vi.mock("@/hooks/use-keyboard-action-handler", () => ({ vi.mock("@/hooks/use-keyboard-action-handler", () => ({
useKeyboardActionHandler: () => {}, useKeyboardActionHandler: (input: unknown) => {
keyboardActionHandlerMock(input);
},
})); }));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({ vi.mock("@/hooks/use-keyboard-shift-style", () => ({
@@ -575,6 +579,10 @@ beforeEach(() => {
vi.stubGlobal("Node", dom.window.Node); vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator); vi.stubGlobal("navigator", dom.window.navigator);
vi.stubGlobal("Blob", dom.window.Blob); vi.stubGlobal("Blob", dom.window.Blob);
Object.assign(dom.window.HTMLElement.prototype, {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -593,6 +601,7 @@ beforeEach(() => {
setAgentStreamTailMock.mockClear(); setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear(); setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear(); setQueuedMessagesMock.mockClear();
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset(); agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready"); agentDirectoryStatusMock.mockReturnValue("ready");
mockSessionState.sessions.server.serverInfo = { mockSessionState.sessions.server.serverInfo = {
@@ -606,6 +615,9 @@ beforeEach(() => {
}, },
}, },
}; };
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "idle", lastUsage: null }],
]);
mockSessionState.sessions.server.agentStreamHead = new Map(); mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map(); mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map(); mockSessionState.sessions.server.queuedMessages = new Map();
@@ -719,12 +731,61 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'); return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
} }
function dispatchAgentInterrupt() {
act(() => {
const registeredHandler = keyboardActionHandlerMock.mock.calls.at(-1)?.[0];
registeredHandler?.handle({ id: "agent.interrupt", scope: "global" });
});
}
function countMessageInputRenders(): number { function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter( return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent", ([componentId]) => componentId === "MessageInput:server:agent",
).length; ).length;
} }
describe("Composer keyboard shortcuts", () => {
it("interrupts a running agent without clearing a filled draft", async () => {
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "running", lastUsage: null }],
]);
renderComposer({ initialText: "keep this prompt" });
await flushAsyncWork();
dispatchAgentInterrupt();
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
expect(document.querySelector('[aria-label="Message agent..."]')).toHaveProperty(
"value",
"keep this prompt",
);
});
it("interrupts a running agent when the message input is unfocused", async () => {
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "running", lastUsage: null }],
]);
renderComposer();
await flushAsyncWork();
const input = document.querySelector('[aria-label="Message agent..."]') as HTMLElement | null;
input?.blur();
dispatchAgentInterrupt();
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
});
it("does not interrupt when the agent is idle", () => {
renderComposer();
dispatchAgentInterrupt();
expect(mockClient.cancelAgent).not.toHaveBeenCalled();
});
});
describe("Composer attachments", () => { describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => { it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer(); renderComposer();
+17 -19
View File
@@ -176,7 +176,7 @@ export function Composer({
toastErrorRef.current = toast.error; toastErrorRef.current = toast.error;
const voice = useVoiceOptional(); const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle"); const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel"); const agentInterruptKeys = useShortcutKeys("agent-interrupt");
const isDictationReady = useIsDictationReady({ const isDictationReady = useIsDictationReady({
serverId, serverId,
isConnected, isConnected,
@@ -525,6 +525,15 @@ export function Composer({
} }
switch (action.id) { switch (action.id) {
case "agent.interrupt":
if (messageInputRef.current?.runKeyboardAction("dictation-cancel")) {
return true;
}
if (!isAgentRunning || isCancellingAgent || !isConnected) {
return false;
}
handleCancelAgent();
return true;
case "message-input.send": case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false; return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-confirm": case "message-input.dictation-confirm":
@@ -560,12 +569,13 @@ export function Composer({
return false; return false;
} }
}, },
[isPaneFocused], [handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused],
); );
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current, handlerId: keyboardHandlerIdRef.current,
actions: [ actions: [
"agent.interrupt",
"message-input.focus", "message-input.focus",
"message-input.send", "message-input.send",
"message-input.dictation-toggle", "message-input.dictation-toggle",
@@ -640,24 +650,12 @@ export function Composer({
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete and stop action. // Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback( const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => { (event: { key: string; preventDefault: () => void }) => {
if (
event.key === "Escape" &&
isAgentRunning &&
!hasSendableContent &&
!isCancellingAgent &&
isConnected
) {
event.preventDefault();
handleCancelAgent();
return true;
}
return autocompleteOnKeyPressRef.current(event); return autocompleteOnKeyPressRef.current(event);
}, },
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent], [],
); );
const cancelButton = useMemo( const cancelButton = useMemo(
@@ -683,16 +681,16 @@ export function Composer({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text> <Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? ( {agentInterruptKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} /> <Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null, ) : null,
[ [
agentInterruptKeys,
buttonIconSize, buttonIconSize,
dictationCancelKeys,
handleCancelAgent, handleCancelAgent,
hasSendableContent, hasSendableContent,
isAgentRunning, isAgentRunning,
@@ -307,8 +307,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (action === "dictation-cancel") { if (action === "dictation-cancel") {
if (isDictatingRef.current) { if (isDictatingRef.current) {
cancelDictation(); cancelDictation();
return true;
} }
return true; return false;
} }
if (action === "dictation-toggle") { if (action === "dictation-toggle") {
@@ -11,6 +11,7 @@ const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
routerMock: { routerMock: {
back: vi.fn(), back: vi.fn(),
push: vi.fn(), push: vi.fn(),
replace: vi.fn(),
}, },
pathState: { pathState: {
pathname: "/h/srv/workspace/ws-2", pathname: "/h/srv/workspace/ws-2",
@@ -56,6 +57,7 @@ vi.mock("@/hooks/use-workspace-navigation", () => ({
})); }));
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { import {
activateNavigationWorkspaceSelection, activateNavigationWorkspaceSelection,
@@ -80,6 +82,7 @@ describe("useKeyboardShortcuts", () => {
navigateToWorkspaceMock.mockReset(); navigateToWorkspaceMock.mockReset();
routerMock.back.mockReset(); routerMock.back.mockReset();
routerMock.push.mockReset(); routerMock.push.mockReset();
routerMock.replace.mockReset();
pathState.pathname = "/h/srv/workspace/ws-2"; pathState.pathname = "/h/srv/workspace/ws-2";
syncNavigationActiveWorkspace({ current: null }); syncNavigationActiveWorkspace({ current: null });
useKeyboardShortcutsStore.setState({ useKeyboardShortcutsStore.setState({
@@ -132,4 +135,57 @@ describe("useKeyboardShortcuts", () => {
}); });
expect(event.defaultPrevented).toBe(true); expect(event.defaultPrevented).toBe(true);
}); });
it("dispatches Escape as an agent interrupt action", async () => {
const handleInterrupt = vi.fn(() => true);
const unregister = keyboardActionDispatcher.registerHandler({
handlerId: "test-agent-interrupt",
actions: ["agent.interrupt"],
enabled: true,
priority: 100,
isActive: () => true,
handle: handleInterrupt,
});
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(handleInterrupt).toHaveBeenCalledTimes(1);
unregister();
});
it("returns from desktop settings to the retained workspace without browser history back", async () => {
syncNavigationActiveWorkspace({
current: {
getCurrentRoute: () => ({ path: "/h/srv/workspace/ws-2" }),
},
});
pathState.pathname = "/settings/general";
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: ",",
code: "Comma",
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(routerMock.replace).toHaveBeenCalledWith("/h/srv/workspace/ws-2");
expect(routerMock.back).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
}); });
@@ -4,6 +4,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
buildHostWorkspaceRoute,
buildSettingsRoute, buildSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -27,7 +28,10 @@ import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts"; import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id"; import { useActiveServerId } from "@/hooks/use-active-server-id";
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import {
getLastNavigationWorkspaceRouteSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -159,6 +163,11 @@ export function useKeyboardShortcuts({
event: KeyboardEvent; event: KeyboardEvent;
}): boolean => { }): boolean => {
switch (input.action) { switch (input.action) {
case "agent.interrupt":
return keyboardActionDispatcher.dispatch({
id: "agent.interrupt",
scope: "global",
});
case "agent.new": case "agent.new":
return openProjectPicker(); return openProjectPicker();
case "workspace.tab.new": case "workspace.tab.new":
@@ -234,6 +243,15 @@ export function useKeyboardShortcuts({
return true; return true;
case "settings.toggle": case "settings.toggle":
if (pathname.startsWith("/settings")) { if (pathname.startsWith("/settings")) {
if (!isMobile) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return true;
}
}
router.back(); router.back();
return true; return true;
} }
+1
View File
@@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind =
| "voice-mute-toggle"; | "voice-mute-toggle";
export type KeyboardActionId = export type KeyboardActionId =
| "agent.interrupt"
| "agent.new" | "agent.new"
| "workspace.tab.new" | "workspace.tab.new"
| "workspace.tab.close.current" | "workspace.tab.close.current"
@@ -1,6 +1,7 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type KeyboardActionId = export type KeyboardActionId =
| "agent.interrupt"
| "message-input.focus" | "message-input.focus"
| "message-input.send" | "message-input.send"
| "message-input.dictation-toggle" | "message-input.dictation-toggle"
@@ -29,6 +30,7 @@ export type KeyboardActionId =
| "worktree.archive"; | "worktree.archive";
export type KeyboardActionDefinition = export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
| { id: "message-input.focus"; scope: KeyboardActionScope } | { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.send"; scope: KeyboardActionScope } | { id: "message-input.send"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
@@ -280,11 +280,10 @@ describe("keyboard-shortcuts", () => {
payload: { kind: "voice-mute-toggle" }, payload: { kind: "voice-mute-toggle" },
}, },
{ {
name: "lets Escape continue to local handlers while routing dictation cancel", name: "routes Escape to agent interrupt outside terminal focus",
event: { key: "Escape", code: "Escape" }, event: { key: "Escape", code: "Escape" },
context: { focusScope: "message-input" }, context: { focusScope: "message-input" },
action: "message-input.action", action: "agent.interrupt",
payload: { kind: "dictation-cancel" },
preventDefault: false, preventDefault: false,
stopPropagation: false, stopPropagation: false,
}, },
@@ -353,6 +352,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "d", code: "KeyD", metaKey: true }, event: { key: "d", code: "KeyD", metaKey: true },
context: { isMac: true, focusScope: "terminal" }, context: { isMac: true, focusScope: "terminal" },
}, },
{
name: "does not interrupt agent when terminal is focused",
event: { key: "Escape", code: "Escape" },
context: { focusScope: "terminal" },
},
{
name: "does not interrupt agent when command center is open",
event: { key: "Escape", code: "Escape" },
context: { commandCenterOpen: true },
},
{ {
name: "does not bind pane shortcuts on non-mac platforms", name: "does not bind pane shortcuts on non-mac platforms",
event: { key: "\\", code: "Backslash", ctrlKey: true }, event: { key: "\\", code: "Backslash", ctrlKey: true },
@@ -869,17 +869,16 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
}, },
{ {
id: "message-input-dictation-cancel", id: "agent-interrupt",
action: "message-input.action", action: "agent.interrupt",
combo: "Escape", combo: "Escape",
when: { commandCenter: false, terminal: false }, when: { commandCenter: false, terminal: false },
payload: { type: "message-input", kind: "dictation-cancel" },
preventDefault: false, preventDefault: false,
stopPropagation: false, stopPropagation: false,
help: { help: {
id: "dictation-cancel", id: "agent-interrupt",
section: "agent-input", section: "agent-input",
label: "Cancel dictation", label: "Interrupt agent",
keys: ["Esc"], keys: ["Esc"],
}, },
}, },