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 {...@@ -137,7 +137,7 @@ export interface ComposerPromptEditorProps {
137 ) => void;137 ) => void;
138 onVisibleSelectionChange?: () => void;138 onVisibleSelectionChange?: () => void;
139 onCommandKeyDown?: (139 onCommandKeyDown?: (
140 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",140 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape",
141 event: KeyboardEvent,141 event: KeyboardEvent,
142 isTaskItem?: boolean,142 isTaskItem?: boolean,
143 ) => boolean;143 ) => boolean;
...@@ -912,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -912,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
912 ? ("ArrowDown" as const)912 ? ("ArrowDown" as const)
913 : event.key === "ArrowUp"913 : event.key === "ArrowUp"
914 ? ("ArrowUp" as const)914 ? ("ArrowUp" as const)
915 : null;915 : event.key === "Escape"
916 ? ("Escape" as const)
917 : null;
916 if (!key) return false;918 if (!key) return false;
917 const handled = handler(key, event);919 const handled = handler(key, event);
918 if (handled) {920 if (handled) {
apps/web/src/components/chat/ChatComposer.tsx+39-13
...@@ -124,6 +124,7 @@ import {...@@ -124,6 +124,7 @@ import {
124import { ComposerStashBadge } from "./ComposerStashBadge";124import { ComposerStashBadge } from "./ComposerStashBadge";
125import { ComposerStashMenu } from "./ComposerStashMenu";125import { ComposerStashMenu } from "./ComposerStashMenu";
126import { useComposerMenuState } from "./useComposerMenuState";126import { useComposerMenuState } from "./useComposerMenuState";
127import { useComposerTriggerState } from "./useComposerTriggerState";
127import { useComposerFocusState } from "./useComposerFocusState";128import { useComposerFocusState } from "./useComposerFocusState";
128import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";129import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";
129import {130import {
...@@ -2065,9 +2066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2065,9 +2066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2065 const [composerCursor, setComposerCursor] = useState(() =>2066 const [composerCursor, setComposerCursor] = useState(() =>
2066 collapseExpandedComposerCursor(prompt, prompt.length),2067 collapseExpandedComposerCursor(prompt, prompt.length),
2067 );2068 );
2068 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>2069 const {
2069 detectComposerTrigger(prompt, prompt.length),2070 trigger: composerTrigger,
2070 );2071 setTrigger: setComposerTrigger,
2072 resolveTrigger: resolveComposerTrigger,
2073 dismissTrigger: dismissComposerTrigger,
2074 resetTrigger: resetComposerTrigger,
2075 } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
2071 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);2076 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
2072 // Active ArrowUp recall. Cleared on edit and on thread switch.2077 // Active ArrowUp recall. Cleared on edit and on thread switch.
2073 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);2078 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
...@@ -2586,7 +2591,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2586,7 +2591,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2586 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));2591 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
2587 scheduleComposerFocus();2592 scheduleComposerFocus();
2588 },2593 },
2589 [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt],2594 [
2595 composerDraftTarget,
2596 promptRef,
2597 scheduleComposerFocus,
2598 setComposerDraftPrompt,
2599 setComposerTrigger,
2600 ],
2590 );2601 );
25912602
2592 const providerTraitsMenuContent = renderProviderTraitsMenuContent({2603 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
...@@ -3090,7 +3101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3090,7 +3101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3090 promptRef.current = prompt;3101 promptRef.current = prompt;
3091 const { cursor, trigger } = composerStateAtPromptEnd(prompt);3102 const { cursor, trigger } = composerStateAtPromptEnd(prompt);
3092 setComposerCursor(cursor);3103 setComposerCursor(cursor);
3093 setComposerTrigger(trigger);3104 resetComposerTrigger(trigger);
3094 }3105 }
3095 lastSyncedPendingInputRef.current = null;3106 lastSyncedPendingInputRef.current = null;
3096 return;3107 return;
...@@ -3115,7 +3126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3115,7 +3126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3115 promptRef.current = nextCustomAnswer;3126 promptRef.current = nextCustomAnswer;
3116 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer);3127 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer);
3117 setComposerCursor(cursor);3128 setComposerCursor(cursor);
3118 setComposerTrigger(trigger);3129 resetComposerTrigger(trigger);
3119 setComposerHighlightedItemId(null);3130 setComposerHighlightedItemId(null);
3120 }, [3131 }, [
3121 activePendingProgress?.customAnswer,3132 activePendingProgress?.customAnswer,
...@@ -3123,6 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3123,6 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3123 activePendingUserInput?.requestId,3134 activePendingUserInput?.requestId,
3124 prompt,3135 prompt,
3125 promptRef,3136 promptRef,
3137 resetComposerTrigger,
3126 ]);3138 ]);
31273139
3128 // ------------------------------------------------------------------3140 // ------------------------------------------------------------------
...@@ -3133,10 +3145,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3133,10 +3145,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3133 setComposerSubmissionError(null);3145 setComposerSubmissionError(null);
3134 setProviderInputSubmissionError(null);3146 setProviderInputSubmissionError(null);
3135 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));3147 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
3136 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));3148 resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
3137 setIsDragOverComposer(false);3149 setIsDragOverComposer(false);
3138 setIsComposerScrollCollapsed(false);3150 setIsComposerScrollCollapsed(false);
3139 }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]);3151 }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]);
31403152
3141 // ------------------------------------------------------------------3153 // ------------------------------------------------------------------
3142 // Footer compact layout observation3154 // Footer compact layout observation
...@@ -3419,6 +3431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3419,6 +3431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3419 onChangeActivePendingUserInputCustomAnswer,3431 onChangeActivePendingUserInputCustomAnswer,
3420 promptRef,3432 promptRef,
3421 setPrompt,3433 setPrompt,
3434 setComposerTrigger,
3422 composerDraftTarget,3435 composerDraftTarget,
3423 composerTerminalContexts,3436 composerTerminalContexts,
3424 setComposerDraftTerminalContexts,3437 setComposerDraftTerminalContexts,
...@@ -3514,6 +3527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3514,6 +3527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3514 onChangeActivePendingUserInputCustomAnswer,3527 onChangeActivePendingUserInputCustomAnswer,
3515 promptRef,3528 promptRef,
3516 setPrompt,3529 setPrompt,
3530 setComposerTrigger,
3517 ],3531 ],
3518 );3532 );
35193533
...@@ -3542,9 +3556,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3542,9 +3556,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3542 const snapshot = readComposerSnapshot();3556 const snapshot = readComposerSnapshot();
3543 return {3557 return {
3544 snapshot,3558 snapshot,
3545 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),3559 trigger: resolveComposerTrigger(
3560 detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
3561 ),
3546 };3562 };
3547 }, [readComposerSnapshot]);3563 }, [readComposerSnapshot, resolveComposerTrigger]);
35483564
3549 const { onUsageLimitsCommand } = props;3565 const { onUsageLimitsCommand } = props;
3550 const onSelectComposerItem = useCallback(3566 const onSelectComposerItem = useCallback(
...@@ -3897,7 +3913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3897,7 +3913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3897 setComposerTrigger(null);3913 setComposerTrigger(null);
3898 setComposerHighlightedItemId(null);3914 setComposerHighlightedItemId(null);
3899 },3915 },
3900 [composerDraftTarget, promptRef, setComposerDraftPrompt],3916 [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger],
3901 );3917 );
39023918
3903 const navigatePromptHistory = useCallback(3919 const navigatePromptHistory = useCallback(
...@@ -3956,7 +3972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3956,7 +3972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3956 // Callbacks: command key3972 // Callbacks: command key
3957 // ------------------------------------------------------------------3973 // ------------------------------------------------------------------
3958 const onComposerCommandKey = (3974 const onComposerCommandKey = (
3959 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",3975 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape",
3960 event: KeyboardEvent,3976 event: KeyboardEvent,
3961 isTaskItem = false,3977 isTaskItem = false,
3962 ) => {3978 ) => {
...@@ -3967,6 +3983,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3967,6 +3983,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3967 }3983 }
3968 const { trigger } = resolveActiveComposerTrigger();3984 const { trigger } = resolveActiveComposerTrigger();
3969 const menuIsActive = composerMenuOpenRef.current || trigger !== null;3985 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 }
3970 if (menuIsActive) {3992 if (menuIsActive) {
3971 const currentItems = composerMenuItemsRef.current;3993 const currentItems = composerMenuItemsRef.current;
3972 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];3994 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
...@@ -4356,6 +4378,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4356,6 +4378,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4356 environmentId,4378 environmentId,
4357 promptRef,4379 promptRef,
4358 setComposerDraftPrompt,4380 setComposerDraftPrompt,
4381 setComposerTrigger,
4359 takeStashEntry,4382 takeStashEntry,
4360 importContextRecords,4383 importContextRecords,
4361 ],4384 ],
...@@ -4619,6 +4642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4619,6 +4642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4619 clearComposerDraftPromptAndImages,4642 clearComposerDraftPromptAndImages,
4620 clearComposerDraftTerminalContexts,4643 clearComposerDraftTerminalContexts,
4621 setComposerDraftPrompt,4644 setComposerDraftPrompt,
4645 setComposerTrigger,
4622 composerDraftTarget,4646 composerDraftTarget,
4623 composerFilesRef,4647 composerFilesRef,
4624 composerImagesRef,4648 composerImagesRef,
...@@ -5932,7 +5956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5932,7 +5956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5932 const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);5956 const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0);
5933 setComposerHighlightedItemId(null);5957 setComposerHighlightedItemId(null);
5934 setComposerCursor(cursor);5958 setComposerCursor(cursor);
5935 setComposerTrigger(5959 resetComposerTrigger(
5936 options?.detectTrigger5960 options?.detectTrigger
5937 ? detectComposerTrigger(5961 ? detectComposerTrigger(
5938 promptForState,5962 promptForState,
...@@ -6041,6 +6065,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6041,6 +6065,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6041 isComposerModelPickerOpen,6065 isComposerModelPickerOpen,
6042 openModelPicker,6066 openModelPicker,
6043 readComposerSnapshot,6067 readComposerSnapshot,
6068 resetComposerTrigger,
6069 setComposerTrigger,
6044 selectedModel,6070 selectedModel,
6045 selectedModelOptionsForDispatch,6071 selectedModelOptionsForDispatch,
6046 selectedModelSelection,6072 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}