From 48d8fc346ba798e6da781bc2a23f6e4cac2903f4 Mon Sep 17 00:00:00 2001 From: David Ondrej Date: Wed, 19 Aug 2026 19:57:59 +0200 Subject: [PATCH 1/2] Cancel an inline sent-message edit with Escape Escape in the sent-message inline editor now cancels the edit (same as the frame's X button) instead of only blurring the composer. The composer gains an optional onEscape that replaces the Escape-to-blur default; typeahead dismissal and voice-recording cancel keep their higher priority. The queued-message inline editor is unchanged. --- .../promptbox/FollowUpPromptBox.tsx | 7 +++ .../promptbox/PromptBoxInternal.test.tsx | 61 +++++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 23 +++++-- .../ThreadDetailPromptArea.test.tsx | 18 ++++++ .../thread-detail/ThreadDetailPromptArea.tsx | 4 ++ 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index f7956043cf..ac8f786cdc 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. + * Inline message editors pass their cancel action; when omitted, Escape + * blurs the editor (the bottom composer's 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..09758424ba 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1543,6 +1543,67 @@ describe("PromptBoxInternal submit shortcuts", () => { }); }); +describe("PromptBoxInternal escape", () => { + 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..4b219d85e7 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). Inline + * message editors pass their 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,17 @@ 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 - // capture listener that stops the event before the editor sees it. A - // locked editor never reaches here — see the editor container below. + // app — or cancels the hosting editor when `onEscape` is provided (the + // inline message editors). 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 +3090,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} ) : 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 ac8f786cdc..80bb9b76cf 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -146,8 +146,8 @@ export interface FollowUpComposerProps { onSubmit: () => void; /** * Escape pressed in the editor with no higher-priority consumer open. - * Inline message editors pass their cancel action; when omitted, Escape - * blurs the editor (the bottom composer's behavior). + * 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. */ diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 09758424ba..f6e744b883 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1544,6 +1544,23 @@ 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(); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 4b219d85e7..becce5675b 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -407,8 +407,8 @@ interface PromptBoxInternalProps { onChange: (value: string, mentionRanges: PromptTextMention[]) => void; onSubmit: () => void; /** - * Replaces the default Escape behavior (blurring the editor). Inline - * message editors pass their cancel action so Escape closes the editor. + * 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. */ @@ -2949,12 +2949,11 @@ export function PromptBoxInternal({ } // Escape releases the composer so the keyboard can reach the rest of the - // app — or cancels the hosting editor when `onEscape` is provided (the - // inline message editors). 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. + // 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();