From fc86ce2a5f8f85ff09d1dd0eb4e90e714f2b084e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Apr 2026 12:52:47 +0700 Subject: [PATCH] Tighten Escape agent interrupt shortcut --- packages/app/src/components/composer.test.tsx | 63 ++++++++++++++++++- packages/app/src/components/composer.tsx | 36 +++++------ packages/app/src/components/message-input.tsx | 3 +- .../src/hooks/use-keyboard-shortcuts.test.tsx | 56 +++++++++++++++++ .../app/src/hooks/use-keyboard-shortcuts.ts | 20 +++++- packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 15 ++++- .../app/src/keyboard/keyboard-shortcuts.ts | 9 ++- 9 files changed, 175 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx index b09732b55..7ce27f38e 100644 --- a/packages/app/src/components/composer.test.tsx +++ b/packages/app/src/components/composer.test.tsx @@ -9,6 +9,8 @@ import type { GitHubSearchItem } from "@server/shared/messages"; import { Composer } from "./composer"; import { splitComposerAttachmentsForSubmit } from "./composer-attachments"; +const keyboardActionHandlerMock = vi.hoisted(() => vi.fn()); + const { theme, imageMetadata, @@ -325,7 +327,9 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({ })); vi.mock("@/hooks/use-keyboard-action-handler", () => ({ - useKeyboardActionHandler: () => {}, + useKeyboardActionHandler: (input: unknown) => { + keyboardActionHandlerMock(input); + }, })); vi.mock("@/hooks/use-keyboard-shift-style", () => ({ @@ -575,6 +579,10 @@ beforeEach(() => { vi.stubGlobal("Node", dom.window.Node); vi.stubGlobal("navigator", dom.window.navigator); vi.stubGlobal("Blob", dom.window.Blob); + Object.assign(dom.window.HTMLElement.prototype, { + attachEvent: vi.fn(), + detachEvent: vi.fn(), + }); container = document.createElement("div"); document.body.appendChild(container); @@ -593,6 +601,7 @@ beforeEach(() => { setAgentStreamTailMock.mockClear(); setAgentStreamHeadMock.mockClear(); setQueuedMessagesMock.mockClear(); + keyboardActionHandlerMock.mockClear(); agentDirectoryStatusMock.mockReset(); agentDirectoryStatusMock.mockReturnValue("ready"); 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.agentStreamTail = new Map(); mockSessionState.sessions.server.queuedMessages = new Map(); @@ -719,12 +731,61 @@ function queryAllAttachmentMenuItems(): NodeListOf { 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 { return markScrollInvestigationRenderMock.mock.calls.filter( ([componentId]) => componentId === "MessageInput:server:agent", ).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", () => { it("opens a Plus menu with image and GitHub attachment actions", () => { renderComposer(); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index b5d290255..c33452a17 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -176,7 +176,7 @@ export function Composer({ toastErrorRef.current = toast.error; const voice = useVoiceOptional(); const voiceToggleKeys = useShortcutKeys("voice-toggle"); - const dictationCancelKeys = useShortcutKeys("dictation-cancel"); + const agentInterruptKeys = useShortcutKeys("agent-interrupt"); const isDictationReady = useIsDictationReady({ serverId, isConnected, @@ -525,6 +525,15 @@ export function Composer({ } 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": return messageInputRef.current?.runKeyboardAction("send") ?? false; case "message-input.dictation-confirm": @@ -560,12 +569,13 @@ export function Composer({ return false; } }, - [isPaneFocused], + [handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused], ); useKeyboardActionHandler({ handlerId: keyboardHandlerIdRef.current, actions: [ + "agent.interrupt", "message-input.focus", "message-input.send", "message-input.dictation-toggle", @@ -640,24 +650,12 @@ export function Composer({ 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( (event: { key: string; preventDefault: () => void }) => { - if ( - event.key === "Escape" && - isAgentRunning && - !hasSendableContent && - !isCancellingAgent && - isConnected - ) { - event.preventDefault(); - handleCancelAgent(); - return true; - } - return autocompleteOnKeyPressRef.current(event); }, - [hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent], + [], ); const cancelButton = useMemo( @@ -683,16 +681,16 @@ export function Composer({ Interrupt - {dictationCancelKeys ? ( - + {agentInterruptKeys ? ( + ) : null} ) : null, [ + agentInterruptKeys, buttonIconSize, - dictationCancelKeys, handleCancelAgent, hasSendableContent, isAgentRunning, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index e83a97261..0af92a72c 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -307,8 +307,9 @@ export const MessageInput = forwardRef(funct if (action === "dictation-cancel") { if (isDictatingRef.current) { cancelDictation(); + return true; } - return true; + return false; } if (action === "dictation-toggle") { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx index 91db839ed..b3ae39981 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx +++ b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx @@ -11,6 +11,7 @@ const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({ routerMock: { back: vi.fn(), push: vi.fn(), + replace: vi.fn(), }, pathState: { pathname: "/h/srv/workspace/ws-2", @@ -56,6 +57,7 @@ vi.mock("@/hooks/use-workspace-navigation", () => ({ })); import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; +import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { activateNavigationWorkspaceSelection, @@ -80,6 +82,7 @@ describe("useKeyboardShortcuts", () => { navigateToWorkspaceMock.mockReset(); routerMock.back.mockReset(); routerMock.push.mockReset(); + routerMock.replace.mockReset(); pathState.pathname = "/h/srv/workspace/ws-2"; syncNavigationActiveWorkspace({ current: null }); useKeyboardShortcutsStore.setState({ @@ -132,4 +135,57 @@ describe("useKeyboardShortcuts", () => { }); 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(); + }); + + 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(); + }); + + 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); + }); }); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 5a0a71ee6..a61bb84e7 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -4,6 +4,7 @@ import { getIsElectronRuntime } from "@/constants/layout"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { + buildHostWorkspaceRoute, buildSettingsRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceRouteFromPathname, @@ -27,7 +28,10 @@ import { isNative } from "@/constants/platform"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts"; 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({ enabled, @@ -159,6 +163,11 @@ export function useKeyboardShortcuts({ event: KeyboardEvent; }): boolean => { switch (input.action) { + case "agent.interrupt": + return keyboardActionDispatcher.dispatch({ + id: "agent.interrupt", + scope: "global", + }); case "agent.new": return openProjectPicker(); case "workspace.tab.new": @@ -234,6 +243,15 @@ export function useKeyboardShortcuts({ return true; case "settings.toggle": if (pathname.startsWith("/settings")) { + if (!isMobile) { + const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection(); + if (lastWorkspaceRoute) { + router.replace( + buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId), + ); + return true; + } + } router.back(); return true; } diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 32916f8ed..09bd73e8f 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind = | "voice-mute-toggle"; export type KeyboardActionId = + | "agent.interrupt" | "agent.new" | "workspace.tab.new" | "workspace.tab.close.current" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 1a1989cd5..88d57b8e7 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -1,6 +1,7 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionId = + | "agent.interrupt" | "message-input.focus" | "message-input.send" | "message-input.dictation-toggle" @@ -29,6 +30,7 @@ export type KeyboardActionId = | "worktree.archive"; export type KeyboardActionDefinition = + | { id: "agent.interrupt"; scope: KeyboardActionScope } | { id: "message-input.focus"; scope: KeyboardActionScope } | { id: "message-input.send"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 38509393a..307d5cf4c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -280,11 +280,10 @@ describe("keyboard-shortcuts", () => { 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" }, context: { focusScope: "message-input" }, - action: "message-input.action", - payload: { kind: "dictation-cancel" }, + action: "agent.interrupt", preventDefault: false, stopPropagation: false, }, @@ -353,6 +352,16 @@ describe("keyboard-shortcuts", () => { event: { key: "d", code: "KeyD", metaKey: true }, 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", event: { key: "\\", code: "Backslash", ctrlKey: true }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 8aac268d6..7c2c93b4a 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -869,17 +869,16 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, { - id: "message-input-dictation-cancel", - action: "message-input.action", + id: "agent-interrupt", + action: "agent.interrupt", combo: "Escape", when: { commandCenter: false, terminal: false }, - payload: { type: "message-input", kind: "dictation-cancel" }, preventDefault: false, stopPropagation: false, help: { - id: "dictation-cancel", + id: "agent-interrupt", section: "agent-input", - label: "Cancel dictation", + label: "Interrupt agent", keys: ["Esc"], }, },