Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
footerStart,
compact,
onSubmit,
onEscape,
blurOnPointerSubmit,
promptBoxRef,
submission,
Expand All @@ -71,6 +72,7 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
placeholder?: string;
};
onSubmit: () => void;
onEscape?: () => void;
blurOnPointerSubmit?: boolean;
promptBoxRef?: {
current: {
Expand Down Expand Up @@ -135,6 +137,11 @@ vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
Collapse prompt box
</button>
) : null}
{onEscape ? (
<button type="button" onClick={onEscape}>
Escape
</button>
) : null}
</div>
),
}));
Expand Down Expand Up @@ -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(<FollowUpPromptBox {...props} />);
fireEvent.click(screen.getByRole("button", { name: "Escape" }));

expect(onEscape).toHaveBeenCalledOnce();
});

it.each([
{
setting: false,
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -725,6 +731,7 @@ function FollowUpPromptBoxWithComposer({
mentionRanges={composer.mentionRanges}
onChange={composer.onChangeMessage}
onSubmit={onPrimarySubmit}
onEscape={composer.onEscape}
blurOnPointerSubmit={isCompactViewport && isPointerCoarse}
textEffects={textEffects}
onComposerLayoutChange={setComposerLayout}
Expand Down
78 changes: 78 additions & 0 deletions apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PromptBoxHandle>();
render(
<PromptBoxInternal
{...createPromptBoxProps({ value: "Follow-up message" })}
promptBoxRef={promptBoxRef}
/>,
);
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<PromptBoxHandle>();
render(
<PromptBoxInternal
{...createPromptBoxProps({ onEscape, value: "Edited message" })}
promptBoxRef={promptBoxRef}
/>,
);
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<PromptBoxHandle>();
render(
<PromptBoxInternal
{...createPromptBoxProps({
onEscape,
value: "/re",
typeahead: buildTypeaheadConfig({
commandSuggestions: [
{
kind: "command",
name: "review",
source: "command",
origin: "user",
description: null,
argumentHint: null,
},
],
}),
})}
promptBoxRef={promptBoxRef}
/>,
);
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)"],
Expand Down
18 changes: 16 additions & 2 deletions apps/app/src/components/promptbox/PromptBoxInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1184,6 +1191,7 @@ export function PromptBoxInternal({
mentionRanges,
onChange,
onSubmit,
onEscape,
blurOnPointerSubmit = false,
placeholder = "Ask anything. @ to mention files, folders, or sections",
autoFocus = true,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -3076,6 +3089,7 @@ export function PromptBoxInternal({
isPointerCoarse,
loadMoreCommands,
onCommandQueryChange,
onEscape,
onMentionQueryChange,
onModifierSubmit,
postCompositionKeyDownEvents,
Expand Down
18 changes: 18 additions & 0 deletions apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -250,6 +251,11 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => {
<button type="button" onClick={composer.onSubmit}>
Submit composer
</button>
{composer.onEscape ? (
<button type="button" onClick={composer.onEscape}>
Escape composer
</button>
) : null}
<button
type="button"
onClick={() =>
Expand Down Expand Up @@ -842,6 +848,18 @@ describe("ThreadDetailPromptArea", () => {
}),
);
expect(onCancel).toHaveBeenCalledTimes(1);

// Escape in the edit composer cancels too; the bottom composer keeps its
// default Escape behavior (no onEscape).
expect(
within(bottomComposer!).queryByRole("button", {
name: "Escape composer",
}),
).toBeNull();
fireEvent.click(
inlineEditor.getByRole("button", { name: "Escape composer" }),
);
expect(onCancel).toHaveBeenCalledTimes(2);
});

it("blocks a staged sent-message edit when the thread becomes ineligible", () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ interface InlineDraftComposerOptions {
historyResetKey: string;
isSubmitting: boolean;
onChangeMessage: FollowUpComposerProps["onChangeMessage"];
/** Escape pressed in the editor; passes the editor's cancel action. */
onEscape?: FollowUpComposerProps["onEscape"];
onSelectHistoryEntry: (draft: PromptDraftState) => void;
permission: FollowUpPromptBoxProps["permission"];
pluginComposerHost: PluginComposerHost;
Expand Down Expand Up @@ -280,6 +282,7 @@ function buildInlineDraftComposer(options: InlineDraftComposerOptions) {
onChangeMessage: options.onChangeMessage,
onModifierSubmit: options.submit,
onSubmit: options.submit,
onEscape: options.onEscape,
submitTitle: options.submitTitle,
compactPromptPlaceholder: options.compactPromptPlaceholder,
promptPlaceholder: options.promptPlaceholder,
Expand Down Expand Up @@ -1437,6 +1440,7 @@ export function ThreadDetailPromptArea({
text,
mentions,
})),
onEscape: sentMessageEdit.onCancel,
onSelectHistoryEntry: (nextDraft) =>
sentMessageEdit.updateDraft(() => nextDraft),
permission: bottomPermissionConfig,
Expand Down
Loading