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
Add a pad @ list where Members open a Comment and Headings insert a link #349
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.
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:
Resolve the heading id at pick time.
Remove the @query text in one transaction, inside the pick undo boundary helper.
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.
End any edit in that heading chat with setEditMessageMemory(headingId, null) (apps/webapp/src/stores/chat/workspaceSettingsStore.ts:38).
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.
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/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.
Parent
#328. Related: #251, #174
What to build
A writer types
@sara please confirminside 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@nametext never opens it.cc @sarathen Enter makes a new line and keeps the text.@querytext and opens a Comment in the heading chat of the caret's Section. Nothing is sent until the writer presses Send.@querytext with the heading text as a heading link, then one unmarked space.CONTEXT.mdnames the list. That name is the sheet title and the listboxaria-label.Blocked by
buildHeadingHrefcopies the whole address bar.Agent brief
Type: HITL — the maintainer must do two things before build. Confirm that #348 opened the gate. Pick the
CONTEXT.mdname 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:
extensions/slash-menu/slash-menu.ts(paths here are underapps/webapp/src/components/TipTap/). One module-level session inslash/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 inonStart(renderSlashMenu.ts:53-54), titled "Insert block" (slash/SlashMenuSheet.tsx:11). The listboxaria-labelis "Insert block" (SlashMenuList.tsx:45).onExitremovesaria-controlsandaria-activedescendantwithout checking the owner (renderSlashMenu.ts:101-102).@tiptap/suggestion3.31.3 matches on every transaction with an empty selection, including caret moves.allowreceivesisActive;shouldShowdoes not (node_modules/.bun/@[email protected]*/…/src/plugin/state.ts:124-138).@username, into pads (chatroom/components/MessageCard/hooks/useCopyMessageToDocHandler.ts:52-56).applyCreate, which stampspreventAutolink(hyperlinkPopovers/commands/applyCreate.ts:34-44, called fromhyperlinkPopovers/hooks/useHyperlinkEditorForm.ts:116-129). The link picker starts with no active row (hyperlinkPopovers/hooks/linkSuggestionStateReducer.ts:10).searchWorkspaceUsers, a 150 ms debounce and acancelledguard (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).setContent(MessageComposer/hooks/useComposerDraft.ts:40-54). That wipes any earlier seed.composerModeKindreturnseditbeforecomment(MessageComposer/hooks/useComposerModeEdge.ts:7).openHeadingChatroomsets comment memory but never clears edit memory (apps/webapp/src/services/openHeadingChatroom.ts:122).Desired behavior:
Opening rule. Register a second
Suggestionplugin with its ownPluginKey,char: '@', andallowedPrefixes: [' ', '\u00a0'](a space and a no-break space). Never usenull,(,"or a CJK letter as a prefix: those let/a(@bopen both lists. InshouldShow, return true when this plugin's state is already active (read it by its key). Otherwise returntransaction.docChanged && !isChangeOrigin(transaction).isChangeOrigincomes 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 includeinlineCodeorhyperlink. Re-check the Editing lock incommand, asslash-menu.ts:64does.Who sees Members. Hide Members for these readers:
settings.joinedWorkspaceis not true (apps/webapp/src/hooks/useJoinWorkspace.ts:36).settings.metadata.isPrivate). Only the owner can open it, so nobody else can be addressed.isDraftis true (apps/webapp/src/hooks/useHandleDraftOnFocus.ts:21). This signal is unverified as the only one.resolveHeadingIdForDocPosreturns 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-labeland 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 asrole="group"witharia-labeland anaria-hiddenvisible label, ashyperlinkPopovers/components/HyperlinkSuggestions.tsx:168-171does. Each list removesaria-controlsandaria-activedescendantonly when the value is its own listbox id. Fix SlashonExitthe 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, activebg-base-200):collectHeadings,filterSuggestionsand theSuggestionRowcontents fromhyperlinkPopovers/. Collect headings once inonStart, then filter on each key.@username, then a second muted line "Add comment" (text-base-content/60 text-xs).everyone. The Mention label is the username, never the display name.Heading pick. Re-read the heading text by
toc-idat pick time. If the heading is gone, refuse and refresh the rows. Set the selection to the@queryrange and callapplyCreatewith the heading text. Take the href from the #331 stored heading-link builder, never frombuildHeadingHref. Then insert one space without the mark. Do not write a second insert path.Member pick. In
command:@querytext in one transaction, inside the pick undo boundary helper.buildTextCommentAnchor(commentAnchor.ts:75). If the block is now empty, anchor to the heading text.setEditMessageMemory(headingId, null)(apps/webapp/src/stores/chat/workspaceSettingsStore.ts:38).composerSeed: { headingId, mention: { id, label } }inTChatRoom(apps/webapp/src/stores/chat/chatroom.ts:10-21).destroyChatRoomthen clears it, because it replaceschatRoom. Overwrite it on every open, ascomposerFocusRequestis.openHeadingChatroom({ headingId, intent: 'comment', anchor })..ProseMirror.Composer seed. In
MessageComposer, apply the seed only afterdraftHydrated, and only whenheadingIdmatches 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
decorationClassanddecorationEmptyClassto classes of this list, asslash-menu.ts:50-52does. 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 theCONTEXT.mdentry with the maintainer's name.Where to start:
apps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.tsandTipTap/slash/(session, list, sheet).apps/webapp/src/components/TipTap/TipTap.tsx(extension list),apps/webapp/src/stores/sheetStore.ts(slashMenuentry),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.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: falsefor 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.mdand AGENTS.md §UI And Theme.Verify:
bun run --filter @docs.plus/webapp typecheckandbun run lint.apps/webapp/cypress/e2e/editor/slash/slash-menu.cy.ts. Usecy.visitEditorandrealTypeas that spec does.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.cc @xwith a matching heading. Assert the list is open with noaria-selected="true"row. Press Enter, and assert the text is unchanged and a new paragraph exists.docChangedcheck, and case 1 must fail. Make row 0 active at start, and case 2 must fail.make dev-local. Note the webapp port; it is not always 3000. Fromapps/webapp, runbunx 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).Out of scope
@everyonein pad text, and date chips. All are cut.padlink kind in the list. Cut.@by design (apps/hocuspocus.server/src/modules/mcp/domain/toChatPost.ts:7).