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}