diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx index aabe06f1b9..6bf26d9245 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx @@ -56,6 +56,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ footerStart, compact, onSubmit, + onEscape, blurOnPointerSubmit, promptBoxRef, submission, @@ -71,6 +72,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ placeholder?: string; }; onSubmit: () => void; + onEscape?: () => void; blurOnPointerSubmit?: boolean; promptBoxRef?: { current: { @@ -135,6 +137,11 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ Collapse prompt box ) : null} + {onEscape ? ( + + ) : null} ), })); @@ -656,6 +663,18 @@ describe("FollowUpPromptBox", () => { expect(mocks.scrollToBottom).toHaveBeenCalledOnce(); }); + it("forwards the composer's host Escape action", () => { + const props = createFollowUpPromptBoxProps({ kind: "ready" }); + const onEscape = vi.fn(); + if (!props.composer) throw new Error("Expected follow-up composer props"); + props.composer.onEscape = onEscape; + + render(); + fireEvent.click(screen.getByRole("button", { name: "Escape" })); + + expect(onEscape).toHaveBeenCalledOnce(); + }); + it.each([ { setting: false, diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index f7956043cf..80bb9b76cf 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -144,6 +144,12 @@ export interface FollowUpComposerProps { onChangeMessage: (value: string, mentionRanges: PromptTextMention[]) => void; onModifierSubmit: () => void; onSubmit: () => void; + /** + * Escape pressed in the editor with no higher-priority consumer open. + * The sent-message editor passes its cancel action; when omitted, Escape + * blurs the editor (the bottom and queued-message composers' behavior). + */ + onEscape?: () => void; /** Accessible label and tooltip for the primary submit action. */ submitTitle?: string; compactPromptPlaceholder: string; @@ -725,6 +731,7 @@ function FollowUpPromptBoxWithComposer({ mentionRanges={composer.mentionRanges} onChange={composer.onChangeMessage} onSubmit={onPrimarySubmit} + onEscape={composer.onEscape} blurOnPointerSubmit={isCompactViewport && isPointerCoarse} textEffects={textEffects} onComposerLayoutChange={setComposerLayout} diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index da51a97b37..f6e744b883 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1543,6 +1543,84 @@ describe("PromptBoxInternal submit shortcuts", () => { }); }); +describe("PromptBoxInternal escape", () => { + it("blurs the editor when no host Escape action is provided", async () => { + const promptBoxRef = createRef(); + render( + , + ); + await focusPromptEnd(promptBoxRef); + const editor = getPromptEditorElement(); + + const wasNotCanceled = fireEvent.keyDown(editor, { key: "Escape" }); + + expect(wasNotCanceled).toBe(false); + expect(document.activeElement).not.toBe(editor); + }); + + it("routes Escape to onEscape instead of blurring the editor", async () => { + const onEscape = vi.fn(); + const promptBoxRef = createRef(); + render( + , + ); + await focusPromptEnd(promptBoxRef); + + const wasNotCanceled = fireEvent.keyDown(getPromptEditorElement(), { + key: "Escape", + }); + + expect(onEscape).toHaveBeenCalledTimes(1); + expect(wasNotCanceled).toBe(false); + // The cancel action owns what happens next; the editor must not also blur. + expect(document.activeElement).toBe(getPromptEditorElement()); + }); + + it("dismisses an open typeahead before Escape reaches onEscape", async () => { + const onEscape = vi.fn(); + const promptBoxRef = createRef(); + render( + , + ); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { name: "review" }); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); + + expect(onEscape).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole("button", { name: "review" })).toBeNull(), + ); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); + expect(onEscape).toHaveBeenCalledTimes(1); + }); +}); + describe("PromptBoxInternal size controls", () => { it.each([ ["thread", "calc(50dvh - 3rem)"], diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 234950b713..becce5675b 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -406,6 +406,13 @@ interface PromptBoxInternalProps { mentionRanges: readonly PromptTextMention[]; onChange: (value: string, mentionRanges: PromptTextMention[]) => void; onSubmit: () => void; + /** + * Replaces the default Escape behavior (blurring the editor). The + * sent-message editor passes its cancel action so Escape closes the editor. + * Higher-priority Escape consumers (typeahead dismissal, voice-recording + * cancel) still run first. + */ + onEscape?: () => void; /** Blur the editor after a pointer-activated primary submission. */ blurOnPointerSubmit?: boolean; placeholder?: string; @@ -1184,6 +1191,7 @@ export function PromptBoxInternal({ mentionRanges, onChange, onSubmit, + onEscape, blurOnPointerSubmit = false, placeholder = "Ask anything. @ to mention files, folders, or sections", autoFocus = true, @@ -2941,11 +2949,16 @@ export function PromptBoxInternal({ } // Escape releases the composer so the keyboard can reach the rest of the - // app. Higher-priority Escape behavior still runs first: the typeahead - // menu above dismisses itself, and voice recording cancels from a window + // app — or cancels the sent-message editor when `onEscape` is provided. + // Higher-priority Escape behavior still runs first: the typeahead menu + // above dismisses itself, and voice recording cancels from a window // capture listener that stops the event before the editor sees it. A // locked editor never reaches here — see the editor container below. if (event.key === "Escape") { + if (onEscape) { + onEscape(); + return true; + } blurPromptEditor(currentEditor); return true; } @@ -3076,6 +3089,7 @@ export function PromptBoxInternal({ isPointerCoarse, loadMoreCommands, onCommandQueryChange, + onEscape, onMentionQueryChange, onModifierSubmit, postCompositionKeyDownEvents, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 720b2c3ff9..5589926d05 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -107,6 +107,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { composer: { message: string; onChangeMessage: (message: string, mentions: []) => void; + onEscape?: () => void; onSubmit: () => void; submitTitle?: string; submitMode: { kind: string; reason?: string }; @@ -250,6 +251,11 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { + {composer.onEscape ? ( + + ) : null}