Skip to content

Add a pad @ list where Members open a Comment and Headings insert a link #349

Description

@HMarzban

Parent

#328. Related: #251, #174

What to build

A writer types @sara please confirm inside a Section, but pad text notifies nobody. Every heading already has a heading chat, and a Comment there notifies through @username. Typing @ in the pad opens a list with two groups. A pick in Members opens a Comment in the Section's heading chat, seeded with a Mention of that member. A pick in Headings inserts a heading link. Nothing new is stored in the pad, and no schema node is added.

Acceptance criteria

  • @ after a space or a no-break space opens the list on local typing only. A caret move past existing @name text never opens it.
  • The list never opens on Title, in a code block, in inline code, or inside a link. It never opens with the Editing lock on or in a read-only editor.
  • Members shows first, then Headings, in a fixed order. Desktop shows at most 5 rows per group; the phone shows at most 4.
  • No row is active until an arrow key moves to it. cc @sara then Enter makes a new line and keeps the text.
  • Tab never picks. Space closes the list. Escape closes it and keeps the typed text.
  • A Member pick removes the @query text and opens a Comment in the heading chat of the caret's Section. Nothing is sent until the writer presses Send.
  • The composer gets a real Mention of the member only when it is empty. A saved draft stays, and the seed is skipped.
  • A Member pick while editing a message in that heading chat ends the edit first. The seed does not land in the edited message.
  • A Heading pick replaces the @query text with the heading text as a heading link, then one unmarked space.
  • One Mod-z after either pick undoes only the pick.
  • Visitors see Headings only, and the member search never runs for them. Members is hidden on a Private pad, a draft pad, and for a reader who is not a workspace member.
  • On the phone, the sheet opens only after one query character, and picks are taps.
  • Empty results show one text that is also the status: "No matching members or headings", or "No matching headings" for visitors.
  • After a Member pick, screen readers hear "Comment to opened in chat".
  • CONTEXT.md names the list. That name is the sheet title and the listbox aria-label.

Blocked by

Agent brief

Type: HITL — the maintainer must do two things before build. Confirm that #348 opened the gate. Pick the CONTEXT.md name for the pad list; chat already owns "Mention Picker". Both answers go in a comment on this issue. After that, an agent can build it alone.

Category: enhancement

Current behavior:

  • Slash menu: extensions/slash-menu/slash-menu.ts (paths here are under apps/webapp/src/components/TipTap/). One module-level session in slash/slashMenuSession.ts. Row 0 is active on every query change (slash/renderSlashMenu.ts:38-43). Hover sets the active row (slash/SlashMenuList.tsx:63). The phone sheet opens in onStart (renderSlashMenu.ts:53-54), titled "Insert block" (slash/SlashMenuSheet.tsx:11). The listbox aria-label is "Insert block" (SlashMenuList.tsx:45).
  • Slash onExit removes aria-controls and aria-activedescendant without checking the owner (renderSlashMenu.ts:101-102).
  • @tiptap/suggestion 3.31.3 matches on every transaction with an empty selection, including caret moves. allow receives isActive; shouldShow does not (node_modules/.bun/@[email protected]*/…/src/plugin/state.ts:124-138).
  • Copy to Doc writes chat text, which can hold @username, into pads (chatroom/components/MessageCard/hooks/useCopyMessageToDocHandler.ts:52-56).
  • Mod-k already inserts a heading link at the caret through applyCreate, which stamps preventAutolink (hyperlinkPopovers/commands/applyCreate.ts:34-44, called from hyperlinkPopovers/hooks/useHyperlinkEditorForm.ts:116-129). The link picker starts with no active row (hyperlinkPopovers/hooks/linkSuggestionStateReducer.ts:10).
  • The chat Mention Picker searches with searchWorkspaceUsers, a 150 ms debounce and a cancelled guard (chatroom/components/MessageComposer/helpers/MentionList.tsx:15, :62-97). It skips the search with no profile. On error it shows "Couldn't load members" (MentionSuggestions.tsx:71).
  • The composer loads its draft after an async read and calls setContent (MessageComposer/hooks/useComposerDraft.ts:40-54). That wipes any earlier seed.
  • composerModeKind returns edit before comment (MessageComposer/hooks/useComposerModeEdge.ts:7). openHeadingChatroom sets comment memory but never clears edit memory (apps/webapp/src/services/openHeadingChatroom.ts:122).

Desired behavior:

Opening rule. Register a second Suggestion plugin with its own PluginKey, char: '@', and allowedPrefixes: [' ', '\u00a0'] (a space and a no-break space). Never use null, (, " or a CJK letter as a prefix: those let /a(@b open both lists. In shouldShow, return true when this plugin's state is already active (read it by its key). Otherwise return transaction.docChanged && !isChangeOrigin(transaction). isChangeOrigin comes from @tiptap/extension-collaboration. This is a code reading, runtime unverified; the Cypress test below proves it.

Refused when. In allow: not editable, isDocumentEditingLocked(), isTitlePos($from), $from.parent.type.spec.code, or caret marks that include inlineCode or hyperlink. Re-check the Editing lock in command, as slash-menu.ts:64 does.

Who sees Members. Hide Members for these readers:

  • A visitor with no profile.
  • A reader whose settings.joinedWorkspace is not true (apps/webapp/src/hooks/useJoinWorkspace.ts:36).
  • A Private pad (settings.metadata.isPrivate). Only the owner can open it, so nobody else can be addressed.
  • A draft pad: ymetadata isDraft is true (apps/webapp/src/hooks/useHandleDraftOnFocus.ts:21). This signal is unverified as the only one.
  • A caret with no heading before it: resolveHeadingIdForDocPos returns null (apps/webapp/src/services/commentAnchor.ts:16).

Session and sheet. Reuse the Slash session, list and sheet; do not copy them into a second folder. Add parameters: listbox id, option id prefix, aria-label and sheet title. Add three switches: row 0 active at start, hover sets the active row, and the phone sheet waits for one query character. The pad @ list sets all three to off. The list renders groups as role="group" with aria-label and an aria-hidden visible label, as hyperlinkPopovers/components/HyperlinkSuggestions.tsx:168-171 does. Each list removes aria-controls and aria-activedescendant only when the value is its own listbox id. Fix Slash onExit the same way.

Keys. ArrowDown and ArrowUp move; the first arrow press activates a row. Enter picks only an arrow-chosen row; otherwise it returns false and the pad makes a new line. A tap picks at once. Tab is never bound: Indent owns Tab. Space closes the list, so the query is one word; a many-word heading narrows by its first word.

Rows. Both groups use the Slash row frame (rounded-field min-h-11 gap-2.5 px-2.5 py-1.5, active bg-base-200):

  • Heading rows reuse collectHeadings, filterSuggestions and the SuggestionRow contents from hyperlinkPopovers/. Collect headings once in onStart, then filter on each key.
  • A Member row shows avatar, name and a muted @username, then a second muted line "Add comment" (text-base-content/60 text-xs).
  • Members mirrors the chat search and states; do not import them. Exclude the current user and everyone. The Mention label is the username, never the display name.
  • Until Members loads, show only its skeleton, then both groups at once, so no row moves.
  • On a search error, keep Members with "Couldn't load members". Hide a group with zero rows.

Heading pick. Re-read the heading text by toc-id at pick time. If the heading is gone, refuse and refresh the rows. Set the selection to the @query range and call applyCreate with the heading text. Take the href from the #331 stored heading-link builder, never from buildHeadingHref. Then insert one space without the mark. Do not write a second insert path.

Member pick. In command:

  1. Resolve the heading id at pick time.
  2. Remove the @query text in one transaction, inside the pick undo boundary helper.
  3. Anchor the Comment to the text of the caret's block with buildTextCommentAnchor (commentAnchor.ts:75). If the block is now empty, anchor to the heading text.
  4. End any edit in that heading chat with setEditMessageMemory(headingId, null) (apps/webapp/src/stores/chat/workspaceSettingsStore.ts:38).
  5. Set a new chat store field composerSeed: { headingId, mention: { id, label } } in TChatRoom (apps/webapp/src/stores/chat/chatroom.ts:10-21). destroyChatRoom then clears it, because it replaces chatRoom. Overwrite it on every open, as composerFocusRequest is.
  6. Call openHeadingChatroom({ headingId, intent: 'comment', anchor }).
  7. Write the announcement into a polite region outside .ProseMirror.

Composer seed. In MessageComposer, apply the seed only after draftHydrated, and only when headingId matches and the composer is empty. Insert a Mention node plus a space, then clear the seed. The effect must also react to a new seed, because a same-room open keeps hydration true.

Phone. Open the no-trap sheet after one query character. A Member pick drops the keyboard, as every chat open path does. The writer then taps the composer. A device check on iOS Safari and Android Chrome is owed.

Editor wiring. Set decorationClass and decorationEmptyClass to classes of this list, as slash-menu.ts:50-52 does. Otherwise every bare @ gets the pad placeholder. Extend the "Slash menu popup" design-system entry with this list and its no-active-row rule. Add the CONTEXT.md entry with the maintainer's name.

Where to start:

  • apps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.ts and TipTap/slash/ (session, list, sheet).
  • apps/webapp/src/components/TipTap/TipTap.tsx (extension list), apps/webapp/src/stores/sheetStore.ts (slashMenu entry), apps/webapp/src/components/BottomSheet.tsx:88-94.
  • apps/webapp/src/components/TipTap/hyperlinkPopovers/ (collectHeadings, filterSuggestions, SuggestionRow, applyCreate).
  • apps/webapp/src/components/chatroom/components/MessageComposer/ (MentionList.tsx, MessageComposer.tsx, hooks/useComposerDraft.ts).
  • apps/webapp/src/services/openHeadingChatroom.ts, apps/webapp/src/services/commentAnchor.ts, apps/webapp/src/stores/chat/chatroom.ts.
  • Line numbers are hints as of 2026-09-28; the agent searches by symbol.

Rules that apply:

  • apps/webapp/src/components/chatroom/CLAUDE.md §Mention Picker: mirror, do not import across features; the label is the username.
  • apps/webapp/src/components/TipTap/CLAUDE.md §Editor Performance.
  • apps/webapp/CLAUDE.md §Mobile Bottom Sheets And Overlays: trapFocus: false for a sheet whose query is typed in the editor; chat open paths drop the keyboard.
  • apps/webapp/CLAUDE.md §Document Comments, and §TOC And Heading Actions.
  • .cursor/docs/design-system.md and AGENTS.md §UI And Theme.
  • AGENTS.md §Test Policy: the Cypress test below is allowed under case (c), an ordering bug that is hard to check by hand.

Verify:

  • bun run --filter @docs.plus/webapp typecheck and bun run lint.
  • Add one Cypress spec beside apps/webapp/cypress/e2e/editor/slash/slash-menu.cy.ts. Use cy.visitEditor and realType as that spec does.
    • Case 1: a paragraph holds ask @name. Click after it and assert the list's listbox does not exist. Press Enter, and assert the text is unchanged and a new paragraph exists.
    • Case 2: type cc @x with a matching heading. Assert the list is open with no aria-selected="true" row. Press Enter, and assert the text is unchanged and a new paragraph exists.
  • Prove it by sabotage, twice. Remove the docChanged check, and case 1 must fail. Make row 0 active at start, and case 2 must fail.
  • Start the local stack with make dev-local. Note the webapp port; it is not always 3000. From apps/webapp, run bunx cypress run --spec cypress/e2e/editor/<new spec> --env EDITOR_BASE_URL=http://localhost:<port>. The webapp Cypress suite does not run in CI yet (Run the webapp Cypress suite in CI #276).
  • In a browser, desktop and phone, light and dark:
    • As a signed-in member, pick a member and send. Check the member gets the notification, including in a heading chat that nobody opened before.
    • Pick a heading, then reload with the heading folded; the link must reach it.
    • As a visitor, check that only Headings shows.
    • On a Private pad as its owner, check that Members is hidden.
  • A phone check needs a mobile user agent; a narrow window is not enough (AGENTS.md §Test Policy).

Out of scope

  • A pad Mention node, notifications from pad text, @everyone in pad text, and date chips. All are cut.
  • A Documents group or a pad link kind in the list. Cut.
  • Tab as a pick key.
  • The kind icon on heading links. Richer internal-link chips, and a kind icon beside the pad link #174 owns that.
  • Mentions from connected apps. MCP chat posts strip @ by design (apps/hocuspocus.server/src/modules/mcp/domain/toChatPost.ts:7).

Activity

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 featuresEditorTiptap & ProsemirrorIdeaUI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions