Skip to content

Let the first Escape close only the mention picker or the link popover #268

Description

@HMarzban

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 14ab7f9c1 with @tiptap/suggestion 3.31.3, is below. Paths are under apps/webapp/src/components/chatroom/components/MessageComposer/.

  1. The plugin handles Escape itself. It calls the renderer's onKeyDown, then dispatchExit(view), then returns true. It ignores the renderer's return value.
  2. dispatchExit dispatches a transaction. The plugin view update runs in the same call stack, reaches its stopped branch, and calls onExit before any await.
  3. Our onExit calls destroyPopup(), which sets the open flag to false and removes the popup (helpers/suggestion.ts:45-54, :157-159).
  4. 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

  1. Open a channel. Choose Edit on one of your messages.
  2. In the composer, type a space and @, so the mention picker opens.
  3. Press Escape once.
  4. See the picker close and edit mode end.
  5. Repeat with Reply. See the picker close and reply mode end. The text stays. Then repeat with the emoji button instead of Escape.
  6. 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

Notes

Possible shapes, for the implementer to choose:

  • 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.

Activity

  1. added
    bugSomething isn't working
    ChatRelated to chat features
    on Sep 14, 2026
  2. HMarzban commented on Sep 22, 2026

    @HMarzban
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChatRelated to chat featuresbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions