You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Let the first Escape close only the mention picker or the link popover #268
With a reply, an edit, or a comment open, closing the mention picker also clears that reply, edit, or comment. On desktop, closing the link popover with Escape does the same.
The documented contract has two steps: "the first Escape closes the picker and the second clears reply/edit" (apps/webapp/src/components/chatroom/CLAUDE.md:79). The code does not keep it.
The cause, traced on 14ab7f9c1 with @tiptap/suggestion 3.31.3, is below. Paths are under apps/webapp/src/components/chatroom/components/MessageComposer/.
The plugin handles Escape itself. It calls the renderer's onKeyDown, then dispatchExit(view), then returns true. It ignores the renderer's return value.
dispatchExit dispatches a transaction. The plugin view update runs in the same call stack, reaches its stopped branch, and calls onExit before any await.
Our onExit calls destroyPopup(), which sets the open flag to false and removes the popup (helpers/suggestion.ts:45-54, :157-159).
The same keydown then bubbles to the window Escape handler in useHandleEscKey (hooks/useHandleEscKey.ts:69-74). Its guard isMentionSuggestionPopupVisible() is already false (:44). So it clears reply, edit, or comment, and calls clearContent(true) for edit and comment (:46-54).
The same thing happens with no key press. dismissComposerMentionSuggestion() closes the picker by dispatching a synthetic Escape keydown with bubbles: true on the editor (helpers/mentionTypes.ts:55-66). These paths fire it while the picker is open:
the emoji button (components/Actions/ActionButtons/EmojiButton.tsx:42);
the mention button, when it toggles the picker closed (components/Actions/ActionButtons/MentionButton.tsx:54);
opening the link dialog, which dismisses first and sets its phase after (stores/composerLinkDialogStore.ts:29, :99-105);
opening the comment composer from a pad comment anchor (apps/webapp/src/services/openHeadingChatroom.ts:139).
The comment composer replaces reply or edit on purpose, so only the first three are defects.
The desktop link popover has the same fault. On desktop, the composer edits links in the pad's hyperlink popover. None of its four Escape steps stops the event: clear the highlighted row, clear the search text, go back, or close (apps/webapp/src/components/TipTap/hyperlinkPopovers/hooks/useHyperlinkEditorForm.ts:168-181). The popover root also hides on Escape without stopping the event (packages/floating-popover/src/createPopover.ts:311-315). The window Escape handler checks only the composer link dialog store, which the phone uses (hooks/useHandleEscKey.ts:30). So on desktop, each Escape in the link popover also clears reply, edit, or comment.
The root cause is shared. The window Escape handler runs on every Escape (hooks/useHandleEscKey.ts:70). It cannot tell whether another surface already used the key.
Measured in happy-dom with the real plugin, the real mentionTypes.ts helpers, and a renderer that copies the Escape return and teardown order of suggestion.ts. Each Escape was a scripted keydown. A test listener on the window ran the same check as the window Escape handler. The link popover case is a code trace only.
Trigger
A capture-phase listener sees the picker
The window Escape handler (test copy)
Scripted Escape keydown
visible
falls through and clears reply, edit, or comment
dismissComposerMentionSuggestion()
visible
falls through and clears reply, edit, or comment
Scripted Escape keydown, no picker open
no picker
clears reply, edit, or comment (correct)
The event arrives with defaultPrevented: true. ProseMirror also prevents the default on every Escape key press in the editor (keyCode 27), picker or not. So defaultPrevented cannot tell the two cases apart.
Two doc lines are wrong for 3.31.3. apps/webapp/src/components/chatroom/CLAUDE.md:106 says the plugin runs onExit only when onKeyDown returns false. It also says a true return leaves the plugin active. Both are false for 3.31.3. The comment at helpers/suggestion.ts:138 says the same.
Steps to reproduce
Open a channel. Choose Edit on one of your messages.
In the composer, type a space and @, so the mention picker opens.
Press Escape once.
See the picker close and edit mode end.
Repeat with Reply. See the picker close and reply mode end. The text stays. Then repeat with the emoji button instead of Escape.
On desktop, choose Edit again. Select a word, open the link popover, and press Escape. See the popover close and edit mode end.
Acceptance criteria
With reply, edit, or comment open, the first Escape closes only the mention picker. A second Escape then clears reply, edit, or comment, as today.
Closing the picker with the emoji button, the mention button, or the link dialog never clears reply, edit, or comment.
Opening the comment composer from a pad comment anchor still replaces reply, edit, or comment memory.
On desktop, no Escape in the link popover clears reply, edit, or comment. This covers each Escape step: clear the highlighted row, clear the search text, go back, and close.
Escape with no picker or popover open still clears reply, edit, or comment.
apps/webapp/src/components/chatroom/CLAUDE.md §Mention Picker and the comment in suggestion.ts state that 3.31.3 exits on Escape whatever onKeyDown returns. §Overlay contention names the mechanism that now keeps the two-step Escape.
Agent Brief
Category: bug Summary: An Escape that closes the mention picker or the link popover must not also reach the window Escape handler.
Current behavior:
The suggestion plugin closes the picker synchronously while it handles Escape. The window Escape handler then runs, sees no picker, and clears reply, edit, or comment. dismissComposerMentionSuggestion() fires the same synthetic event, so the buttons and the link dialog do it too. On desktop, the link popover closes on Escape without stopping the event, with the same result.
Desired behavior:
Closing the picker or the popover consumes the event. An Escape that closes the picker or the popover never clears reply, edit, or comment.
Key interfaces:
useHandleEscKey() — the window Escape handler. It checks the link dialog, emoji, and mention in that order, then clears reply, edit, or comment.
isMentionSuggestionPopupVisible() and getMentionPopupOpen() — picker state.
dismissComposerMentionSuggestion(editor) — the synthetic Escape that the emoji button, the mention button, the link dialog, and the comment composer fire.
The mention renderer's onKeyDown and onExit.
@tiptap/suggestion 3.31.3 Escape handling.
The pad hyperlink popover's Escape handling, which the desktop composer reuses.
Out of scope
Enter handling while the picker is open.
The link dialog and emoji checks in the window Escape handler.
Stop propagation in the mention renderer when it handles Escape. This also covers the synthetic path.
Add a separate capture-phase listener that only records whether the picker is open. Keep the window Escape handler in the bubble phase.
Do not skip on defaultPrevented. ProseMirror sets it on every Escape key press in the editor, so Escape with no picker would stop clearing.
Keep the link popover fix in the webapp, in the desktop popover entries or the hyperlink editor form. That form also serves the pad's link popover. Do not change Escape handling in packages/floating-popover. That package is bundled into extension-hyperlink and extension-hypermultimedia.
#277 covers what the composer shows when an edit ends.
The whole chain is synchronous, so a Cypress spec that dispatches Escape from script reproduces it.
Closing. Fixed in 9806869. Checked on desktop Chrome (B1, B2, B4, B5) and with a hardware keyboard on a phone on 2026-09-22: the first Escape closes only the mention list or the link popover, and the reply, edit, or comment mode stays.
Problem
With a reply, an edit, or a comment open, closing the mention picker also clears that reply, edit, or comment. On desktop, closing the link popover with Escape does the same.
The documented contract has two steps: "the first Escape closes the picker and the second clears reply/edit" (
apps/webapp/src/components/chatroom/CLAUDE.md:79). The code does not keep it.The cause, traced on
14ab7f9c1with@tiptap/suggestion3.31.3, is below. Paths are underapps/webapp/src/components/chatroom/components/MessageComposer/.onKeyDown, thendispatchExit(view), then returnstrue. It ignores the renderer's return value.dispatchExitdispatches a transaction. The plugin view update runs in the same call stack, reaches itsstoppedbranch, and callsonExitbefore anyawait.onExitcallsdestroyPopup(), which sets the open flag to false and removes the popup (helpers/suggestion.ts:45-54,:157-159).keydownthen bubbles to the window Escape handler inuseHandleEscKey(hooks/useHandleEscKey.ts:69-74). Its guardisMentionSuggestionPopupVisible()is already false (:44). So it clears reply, edit, or comment, and callsclearContent(true)for edit and comment (:46-54).The same thing happens with no key press.
dismissComposerMentionSuggestion()closes the picker by dispatching a synthetic Escapekeydownwithbubbles: trueon the editor (helpers/mentionTypes.ts:55-66). These paths fire it while the picker is open:components/Actions/ActionButtons/EmojiButton.tsx:42);components/Actions/ActionButtons/MentionButton.tsx:54);stores/composerLinkDialogStore.ts:29,:99-105);apps/webapp/src/services/openHeadingChatroom.ts:139).The comment composer replaces reply or edit on purpose, so only the first three are defects.
The desktop link popover has the same fault. On desktop, the composer edits links in the pad's hyperlink popover. None of its four Escape steps stops the event: clear the highlighted row, clear the search text, go back, or close (
apps/webapp/src/components/TipTap/hyperlinkPopovers/hooks/useHyperlinkEditorForm.ts:168-181). The popover root also hides on Escape without stopping the event (packages/floating-popover/src/createPopover.ts:311-315). The window Escape handler checks only the composer link dialog store, which the phone uses (hooks/useHandleEscKey.ts:30). So on desktop, each Escape in the link popover also clears reply, edit, or comment.The root cause is shared. The window Escape handler runs on every Escape (
hooks/useHandleEscKey.ts:70). It cannot tell whether another surface already used the key.Measured in happy-dom with the real plugin, the real
mentionTypes.tshelpers, and a renderer that copies the Escape return and teardown order ofsuggestion.ts. Each Escape was a scriptedkeydown. A test listener on the window ran the same check as the window Escape handler. The link popover case is a code trace only.keydowndismissComposerMentionSuggestion()keydown, no picker openThe event arrives with
defaultPrevented: true. ProseMirror also prevents the default on every Escape key press in the editor (keyCode 27), picker or not. SodefaultPreventedcannot tell the two cases apart.Two doc lines are wrong for 3.31.3.
apps/webapp/src/components/chatroom/CLAUDE.md:106says the plugin runsonExitonly whenonKeyDownreturns false. It also says atruereturn leaves the plugin active. Both are false for 3.31.3. The comment athelpers/suggestion.ts:138says the same.Steps to reproduce
@, so the mention picker opens.Acceptance criteria
apps/webapp/src/components/chatroom/CLAUDE.md§Mention Picker and the comment insuggestion.tsstate that 3.31.3 exits on Escape whateveronKeyDownreturns. §Overlay contention names the mechanism that now keeps the two-step Escape.Agent Brief
Category: bug
Summary: An Escape that closes the mention picker or the link popover must not also reach the window Escape handler.
Current behavior:
The suggestion plugin closes the picker synchronously while it handles Escape. The window Escape handler then runs, sees no picker, and clears reply, edit, or comment.
dismissComposerMentionSuggestion()fires the same synthetic event, so the buttons and the link dialog do it too. On desktop, the link popover closes on Escape without stopping the event, with the same result.Desired behavior:
Closing the picker or the popover consumes the event. An Escape that closes the picker or the popover never clears reply, edit, or comment.
Key interfaces:
useHandleEscKey()— the window Escape handler. It checks the link dialog, emoji, and mention in that order, then clears reply, edit, or comment.isMentionSuggestionPopupVisible()andgetMentionPopupOpen()— picker state.dismissComposerMentionSuggestion(editor)— the synthetic Escape that the emoji button, the mention button, the link dialog, and the comment composer fire.onKeyDownandonExit.@tiptap/suggestion3.31.3 Escape handling.Out of scope
@tiptap/suggestion.packages/floating-popover.Notes
Possible shapes, for the implementer to choose:
Do not skip on
defaultPrevented. ProseMirror sets it on every Escape key press in the editor, so Escape with no picker would stop clearing.Keep the link popover fix in the webapp, in the desktop popover entries or the hyperlink editor form. That form also serves the pad's link popover. Do not change Escape handling in
packages/floating-popover. That package is bundled intoextension-hyperlinkandextension-hypermultimedia.#277 covers what the composer shows when an edit ends.
The whole chain is synchronous, so a Cypress spec that dispatches Escape from script reproduces it.