authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-20 22:29:55-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 22:29:55-05:00
loga4bc7deb992cc3d48dd4a40ee1f84254f190f318
tree5ff38dd6a55658018620fbb3561a5035b2e1bf16
parenta9ab9049bbbab89fe23dcfb26c3169ef70a0597e
signature Signed by PGP key B5690EEEBB952194

fix(web): dismiss composer suggestions with Escape (#12836)


4 files changed, 230 insertions(+), 15 deletions(-)

apps/web/src/components/ComposerPromptEditorTiptap.tsx+4-2
......@@ -137,7 +137,7 @@ export interface ComposerPromptEditorProps {
137137 ) => void;
138138 onVisibleSelectionChange?: () => void;
139139 onCommandKeyDown?: (
140 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
140 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape",
141141 event: KeyboardEvent,
142142 isTaskItem?: boolean,
143143 ) => boolean;
......@@ -912,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
912912 ? ("ArrowDown" as const)
913913 : event.key === "ArrowUp"
914914 ? ("ArrowUp" as const)
915 : null;
915 : event.key === "Escape"
916 ? ("Escape" as const)
917 : null;
916918 if (!key) return false;
917919 const handled = handler(key, event);
918920 if (handled) {
apps/web/src/components/chat/ChatComposer.tsx+39-13
......@@ -124,6 +124,7 @@ import {
124124import { ComposerStashBadge } from "./ComposerStashBadge";
125125import { ComposerStashMenu } from "./ComposerStashMenu";
126126import { useComposerMenuState } from "./useComposerMenuState";
127import { useComposerTriggerState } from "./useComposerTriggerState";
127128import { useComposerFocusState } from "./useComposerFocusState";
128129import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";
129130import {
......@@ -2065,9 +2066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20652066 const [composerCursor, setComposerCursor] = useState(() =>
20662067 collapseExpandedComposerCursor(prompt, prompt.length),
20672068 );
2068 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
2069 detectComposerTrigger(prompt, prompt.length),
2070 );
2069 const {
2070 trigger: composerTrigger,
2071 setTrigger: setComposerTrigger,
2072 resolveTrigger: resolveComposerTrigger,
2073 dismissTrigger: dismissComposerTrigger,
2074 resetTrigger: resetComposerTrigger,
2075 } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
20712076 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
20722077 // Active ArrowUp recall. Cleared on edit and on thread switch.
20732078 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
......@@ -2586,7 +2591,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25862591 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
25872592 scheduleComposerFocus();
25882593 },
2589 [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt],
2594 [
2595 composerDraftTarget,
2596 promptRef,
2597 scheduleComposerFocus,
2598 setComposerDraftPrompt,
2599 setComposerTrigger,
2600 ],
25902601 );
25912602
25922603 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
......@@ -3090,7 +3101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30903101 promptRef.current = prompt;
30913102 const { cursor, trigger } = composerStateAtPromptEnd(prompt);
30923103 setComposerCursor(cursor);
3093 setComposerTrigger(trigger);
3104 resetComposerTrigger(trigger);
30943105 }
30953106 lastSyncedPendingInputRef.current = null;
30963107 return;
......@@ -3115,7 +3126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31153126 promptRef.current = nextCustomAnswer;
31163127 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer);
31173128 setComposerCursor(cursor);
3118 setComposerTrigger(trigger);
3129 resetComposerTrigger(trigger);
31193130 setComposerHighlightedItemId(null);
31203131 }, [
31213132 activePendingProgress?.customAnswer,
......@@ -3123,6 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31233134 activePendingUserInput?.requestId,
31243135 prompt,
31253136 promptRef,
3137 resetComposerTrigger,
31263138 ]);
31273139
31283140 // ------------------------------------------------------------------
......@@ -3133,10 +3145,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31333145 setComposerSubmissionError(null);
31343146 setProviderInputSubmissionError(null);
31353147 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
3136 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
3148 resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
31373149 setIsDragOverComposer(false);
31383150 setIsComposerScrollCollapsed(false);
3139 }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]);
3151 }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]);
31403152
31413153 // ------------------------------------------------------------------
31423154 // Footer compact layout observation
......@@ -3419,6 +3431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34193431 onChangeActivePendingUserInputCustomAnswer,
34203432 promptRef,
34213433 setPrompt,
3434 setComposerTrigger,
34223435 composerDraftTarget,
34233436 composerTerminalContexts,
34243437 setComposerDraftTerminalContexts,
......@@ -3514,6 +3527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35143527 onChangeActivePendingUserInputCustomAnswer,
35153528 promptRef,
35163529 setPrompt,
3530 setComposerTrigger,
35173531 ],
35183532 );
35193533
......@@ -3542,9 +3556,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35423556 const snapshot = readComposerSnapshot();
35433557 return {
35443558 snapshot,
3545 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
3559 trigger: resolveComposerTrigger(
3560 detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
3561 ),
35463562 };
3547 }, [readComposerSnapshot]);
3563 }, [readComposerSnapshot, resolveComposerTrigger]);
35483564
35493565 const { onUsageLimitsCommand } = props;
35503566 const onSelectComposerItem = useCallback(
......@@ -3897,7 +3913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38973913 setComposerTrigger(null);
38983914 setComposerHighlightedItemId(null);
38993915 },
3900 [composerDraftTarget, promptRef, setComposerDraftPrompt],
3916 [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger],
39013917 );
39023918
39033919 const navigatePromptHistory = useCallback(
......@@ -3956,7 +3972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39563972 // Callbacks: command key
39573973 // ------------------------------------------------------------------
39583974 const onComposerCommandKey = (
3959 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
3975 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape",
39603976 event: KeyboardEvent,
39613977 isTaskItem = false,
39623978 ) => {
......@@ -3967,6 +3983,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39673983 }
39683984 const { trigger } = resolveActiveComposerTrigger();
39693985 const menuIsActive = composerMenuOpenRef.current || trigger !== null;
3986 if (key === "Escape") {
3987 if (!menuIsActive || event.isComposing || event.keyCode === 229) return false;
3988 dismissComposerTrigger(trigger);
3989 composerMenuOpenRef.current = false;
3990 return true;
3991 }
39703992 if (menuIsActive) {
39713993 const currentItems = composerMenuItemsRef.current;
39723994 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
......@@ -4356,6 +4378,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
43564378 environmentId,
43574379 promptRef,
43584380 setComposerDraftPrompt,
4381 setComposerTrigger,
43594382 takeStashEntry,
43604383 importContextRecords,
43614384 ],
......@@ -4619,6 +4642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
46194642 clearComposerDraftPromptAndImages,
46204643 clearComposerDraftTerminalContexts,
46214644 setComposerDraftPrompt,
4645 setComposerTrigger,
46224646 composerDraftTarget,
46234647 composerFilesRef,
46244648 composerImagesRef,
......@@ -5932,7 +5956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
59325956 const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);
59335957 setComposerHighlightedItemId(null);
59345958 setComposerCursor(cursor);
5935 setComposerTrigger(
5959 resetComposerTrigger(
59365960 options?.detectTrigger
59375961 ? detectComposerTrigger(
59385962 promptForState,
......@@ -6041,6 +6065,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
60416065 isComposerModelPickerOpen,
60426066 openModelPicker,
60436067 readComposerSnapshot,
6068 resetComposerTrigger,
6069 setComposerTrigger,
60446070 selectedModel,
60456071 selectedModelOptionsForDispatch,
60466072 selectedModelSelection,
apps/web/src/components/chat/useComposerTriggerState.test.tsx created+145
......@@ -0,0 +1,145 @@
1import { act, StrictMode, useLayoutEffect } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { detectComposerTrigger } from "../../composer-logic";
6import { useComposerTriggerState } from "./useComposerTriggerState";
7
8const command = "pnpm install -g @openai/codex@latest";
9const initialPrompt = "pnpm install -g @openai";
10let root: Root;
11let composer: ReturnType<typeof useComposerTriggerState>;
12
13function ComposerProbe() {
14 const state = useComposerTriggerState(() =>
15 detectComposerTrigger(initialPrompt, initialPrompt.length),
16 );
17 useLayoutEffect(() => {
18 composer = state;
19 });
20 return null;
21}
22
23async function updatePrompt(text: string, cursor = text.length) {
24 await act(() => composer.setTrigger(detectComposerTrigger(text, cursor)));
25}
26
27beforeEach(async () => {
28 // The probe renders no DOM nodes, but ReactDOM still needs an event target.
29 const document = {
30 nodeType: 9,
31 addEventListener() {},
32 removeEventListener() {},
33 };
34 const container = {
35 nodeType: 1,
36 tagName: "DIV",
37 namespaceURI: "http://www.w3.org/1999/xhtml",
38 ownerDocument: document,
39 addEventListener() {},
40 removeEventListener() {},
41 };
42 vi.stubGlobal("document", document);
43 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
44 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
45 root = createRoot(container as unknown as HTMLElement);
46 await act(() =>
47 root.render(
48 <StrictMode>
49 <ComposerProbe />
50 </StrictMode>,
51 ),
52 );
53});
54
55afterEach(async () => {
56 await act(() => root.unmount());
57 vi.unstubAllGlobals();
58});
59
60describe("composer suggestion dismissal", () => {
61 it("closes suggestions and rejects keyboard selection before the next render", async () => {
62 const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
63 expect(composer.trigger).toEqual(candidate);
64
65 await act(() => {
66 composer.dismissTrigger(candidate);
67 expect(composer.resolveTrigger(candidate)).toBeNull();
68 });
69 expect(composer.trigger).toBeNull();
70 });
71
72 it("stays dismissed while typing a scoped package, including its second @", async () => {
73 await act(() => composer.dismissTrigger(composer.trigger));
74
75 for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) {
76 const text = command.slice(0, cursor);
77 await updatePrompt(text);
78 expect(composer.trigger).toBeNull();
79 expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull();
80 }
81 });
82
83 it("stays dismissed while deleting characters or moving within the same word", async () => {
84 await act(() => composer.dismissTrigger(composer.trigger));
85
86 for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) {
87 await updatePrompt(initialPrompt, cursor);
88 expect(composer.trigger).toBeNull();
89 await updatePrompt(initialPrompt.slice(0, cursor));
90 expect(composer.trigger).toBeNull();
91 }
92 });
93
94 it("opens suggestions for a new @ word after a space", async () => {
95 await act(() => composer.dismissTrigger(composer.trigger));
96 await updatePrompt(`${command} `);
97 await updatePrompt(`${command} @src`);
98
99 expect(composer.trigger?.query).toBe("src");
100 expect(composer.trigger?.rangeStart).toBe(command.length + 1);
101 });
102
103 it("opens a different token when the caret moves directly to it", async () => {
104 await act(() => composer.dismissTrigger(composer.trigger));
105 await updatePrompt(`${command} @src`);
106
107 expect(composer.trigger?.query).toBe("src");
108 });
109
110 it("can reopen after the caret leaves the dismissed word", async () => {
111 await act(() => composer.dismissTrigger(composer.trigger));
112 await updatePrompt(initialPrompt, 0);
113 await updatePrompt(initialPrompt);
114
115 expect(composer.trigger?.query).toBe("openai");
116 });
117
118 it("can reopen at the same position after deleting and retyping @", async () => {
119 await act(() => composer.dismissTrigger(composer.trigger));
120 const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@"));
121 await updatePrompt(prefix);
122 await updatePrompt(`${prefix}@`);
123
124 expect(composer.trigger?.query).toBe("");
125 });
126
127 it("clears dismissal when switching drafts or pending questions", async () => {
128 await act(() => composer.dismissTrigger(composer.trigger));
129 const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
130 await act(() => composer.resetTrigger(candidate));
131
132 expect(composer.trigger).toEqual(candidate);
133 expect(composer.resolveTrigger(candidate)).toEqual(candidate);
134 });
135
136 it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => {
137 await updatePrompt(text);
138 await act(() => composer.dismissTrigger(composer.trigger));
139 await updatePrompt(`${text}x`);
140
141 expect(composer.trigger).toBeNull();
142 await updatePrompt("@src");
143 expect(composer.trigger?.kind).toBe("path");
144 });
145});
apps/web/src/components/chat/useComposerTriggerState.ts created+42
......@@ -0,0 +1,42 @@
1import { useCallback, useRef, useState } from "react";
2
3import type { ComposerTrigger } from "../../composer-logic";
4
5/** Keep a dismissed suggestion closed until the caret leaves its token. */
6export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) {
7 const [trigger, setActiveTrigger] = useState(initialTrigger);
8 const dismissedTriggerRef = useRef<ComposerTrigger | null>(null);
9
10 const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => {
11 const dismissed = dismissedTriggerRef.current;
12 return candidate &&
13 dismissed &&
14 candidate.kind === dismissed.kind &&
15 candidate.rangeStart === dismissed.rangeStart
16 ? null
17 : candidate;
18 }, []);
19
20 const setTrigger = useCallback(
21 (candidate: ComposerTrigger | null) => {
22 const activeTrigger = resolveTrigger(candidate);
23 if (candidate === null || activeTrigger !== null) {
24 dismissedTriggerRef.current = null;
25 }
26 setActiveTrigger(activeTrigger);
27 },
28 [resolveTrigger],
29 );
30
31 const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => {
32 dismissedTriggerRef.current = candidate;
33 setActiveTrigger(null);
34 }, []);
35
36 const resetTrigger = useCallback((candidate: ComposerTrigger | null) => {
37 dismissedTriggerRef.current = null;
38 setActiveTrigger(candidate);
39 }, []);
40
41 return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger };
42}