Tighten Escape agent interrupt shortcut
This commit is contained in:
@@ -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<HTMLElement> {
|
||||
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();
|
||||
|
||||
@@ -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({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
{agentInterruptKeys ? (
|
||||
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
[
|
||||
agentInterruptKeys,
|
||||
buttonIconSize,
|
||||
dictationCancelKeys,
|
||||
handleCancelAgent,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
|
||||
@@ -307,8 +307,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
if (action === "dictation-cancel") {
|
||||
if (isDictatingRef.current) {
|
||||
cancelDictation();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action === "dictation-toggle") {
|
||||
|
||||
Reference in New Issue
Block a user