Repository navigation
Undo a Slash pick with one Mod-z, and hide rows that lift their own block #332
Copy link
Copy link
Open
Labels
EditorTiptap & ProsemirrorTiptap & ProsemirrorbugSomething isn't workingSomething isn't workingtiptap
Description
Parent
What to build
After /h2 and Enter, one Mod-z gives different results by typing speed, because a Slash pick has no undo boundary of its own. It can also remove the typing before the /, and in a shared pad that lands on shared text. Two rows also undo their own block: "Bullet List" in an empty bullet item, and "Blockquote" inside a blockquote, each lift the line out.
After the fix, a pick is exactly one undo step, and each row does what its label says.
Acceptance criteria
- Type
/h2and press Enter. The pick adds exactly one step to the undo stack, at any typing speed. - Text typed right after a pick is its own undo step. It does not merge into the pick.
- On desktop, one Mod-z after the pick gives back the
/h2paragraph, and the Slash menu opens on it again. - In an empty item, the row whose type equals the nearest list is absent. Example:
/bulin a bullet item shows no "Bullet List" row. - The other list rows stay. "Ordered List" inside a bullet list still shows, as the toolbar button does.
- Inside any blockquote, including a list item inside a quote, the "Blockquote" row is absent.
- Two new Cypress cases pass. Each case fails when its fix is removed (prove it by sabotage).
Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone.
Category: bug
Current behavior:
- The pick runs
deleteRange(range), thenitem.run(editor), and sets no undo boundary (apps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.ts:63-70). - The Yjs
UndoManagermerges changes less than 500 ms apart (captureTimeout = 500,yjs13.6.32dist/yjs.mjs:3580). The pad'syUndoPluginsets no other value (@tiptap/y-tiptapdist/y-tiptap.js:2866-2869). - So a fast
/h2Enter merges the pick into the typing. A slow one does not. This is code-traced. No browser run has shown it yet; the new test does that first. - The Bullet List, Ordered List, Task List and Blockquote rows use
can: always(apps/webapp/src/components/TipTap/slash/slashItems.ts:55-86). toggleListwith the same list type callsliftListItem(@tiptap/coresrc/commands/toggleList.ts).toggleWrapcallsliftwhen a blockquote is already active (@tiptap/coresrc/commands/toggleWrap.ts:25-28).
Desired behavior:
- Stop Yjs capture before
deleteRangeand again afteritem.run. The first call keeps the pick apart from the typing. The second keeps the next keys apart from the pick. - The heading
toc-idstamp runs inappendTransaction(@tiptap/extension-unique-id3.31.3dist/index.js:118). That lands in the same dispatch, so the second call does not split it from the heading. - After a pick,
@tiptap/suggestion3.31.3 sets no dismissed range. It sets one only on exit. So the restored/h2opens the list again. The test pins that. - Hide a list row when the nearest list ancestor has the same type. Hide Blockquote when
editor.isActive('blockquote').
Where to start: Line numbers are hints as of 2026-09-28; the agent searches by symbol.
- Add
apps/webapp/src/components/TipTap/slash/pickUndoBoundary.ts. It exports one function that callsyUndoPluginKey.getState(editor.state)?.undoManager.stopCapturing(). The same handle is used attoolbar/desktop/applyImportedContent.ts:20. The webapp and@tiptap/extension-collaborationresolve the same@tiptap/y-tiptapcopy (3.0.9), so this key finds the pad's undo plugin. Optional chaining covers theUndoRedopath, where there is no Yjs undo. - Keep the helper in its own file. The pad
@list (Add a pad @ list where Members open a Comment and Headings insert a link #349) will use the same undo boundary. - In
slash-menu.tscommand, call the helper beforedeleteRangeand afteritem.run. - Add
apps/webapp/src/components/TipTap/slash/slashContext.ts. It is the one home for caret checks that rowcanfunctions use. PutnearestListType(editor)there. Build it withfindParentNodefrom@tiptap/coreoverbulletList,orderedListandtaskList. - Do not use
isActivefor lists. Lists nest, andisActiveis true for any list ancestor. - In
slashItems.ts, replacecan: alwayson the three list rows and on Blockquote.
Rules that apply:
CONTEXT.md§Pad tools: each Slash row runs an existing editor command.apps/webapp/src/components/TipTap/CLAUDE.md§Editor Performance:items()runs on every keystroke, so keep the new checks cheap. Walk only the caret depth.AGENTS.md§Test Policy: the undo case is allowed under (c), an ordering bug that hand checks cannot pin. The rows case is allowed under (b): the defect is onmainsince commite7271cc54. Prove each by sabotage.AGENTS.md§Test Orchestration And Authoring: userealType.cy.realPressis a parent command, so gate on editor focus first.
Verify:
- Add the undo case to
apps/webapp/cypress/e2e/editor/slash/slash-menu.cy.ts:- The spec's
beforeEachvisits/editorwithpersist: true. That loadsCollaborationon a local Y.Doc, so the pad has a Yjs undo manager. Without it the playground uses plainUndoRedo(apps/webapp/src/components/TipTap/TipTap.tsx:249). Keeppersist: true. - After
startIn, stop capture once, because itssetContentwould merge with fast typing. - Reach the undo manager through
window._editor.state.plugins. Find the plugin whosekeystarts withy-undo$, then callgetState(state).undoManager.new PluginKey('y-undo')(@tiptap/y-tiptapdist/y-tiptap.js:35) gives the keyy-undo$, ory-undo$1if a second copy loads. Fail the test when the lookup finds nothing. Add no window globals. realType('/h2'). ReadundoStack.lengthas L. Press Enter, thenrealType('x')at once. Assert the length is L + 2.- Pick the modifier like
cypress/e2e/editor/find/caret-find.cy.ts:18:Metaondarwin,Controlelsewhere.keyboard-shortcuts.cy.jspresses Mod-z but asserts nothing, so do not copy it. - Mod-z once: the block is Heading 2 with empty text. Mod-z again: the block is a paragraph
/h2, and[data-testid="slash-menu"]exists. - Do not add a timed wait.
- The spec's
- Add the rows case to the same spec:
- Start in
<h1>Doc</h1><ul><li><p></p></li></ul>and type/. Assert no[data-slash-item="bulletList"], and assertorderedListandtaskListare present. - Start in
<h1>Doc</h1><blockquote><p></p></blockquote>and type/. Assert no[data-slash-item="blockquote"].
- Start in
- From the repo root, run
bun run check. - Start the local stack with
make dev-localfrom the repo root. On first run it writes.env.local, whichbun run devinapps/webappneeds. Note the webapp port; it is not always 3000 (AGENTS.md§Test Policy;README.mdlocal development). - From
apps/webapp, runbunx cypress run --spec cypress/e2e/editor/slash/slash-menu.cy.ts --env EDITOR_BASE_URL=http://localhost:<port>. All cases pass. - Sabotage, one at a time, and watch a case fail each time:
- Remove the helper call before
deleteRange. - Remove the helper call after
item.run. - Restore
can: alwayson Bullet List. - Restore
can: alwayson Blockquote.
Put each fix back after it fails. Run the sabotage locally: a slow runner can stretch the gap before Enter past 500 ms and hide the merge.
- Remove the helper call before
- No look changes, so no light and dark check is needed. The phone sheet uses the same
filterSlashItems, so it gets the same rows.
Out of scope
- Code Block in a list item lifts the item before it converts (
@tiptap/coresrc/commands/setNode.tsfalls back toclearNodes). The toolbar does the same. Accept it. - "Ordered List" in a bullet list changes the whole list. That matches the toolbar.
- A second
cancheck at pick time. On failure it would close the list and leave a dead/query. - The empty-result status and option ids — Announce an empty Slash menu result, and give each option a stable id #333.
- Markdown hints on rows — Show the Markdown shortcut on each Slash menu row that has one #346.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
EditorTiptap & ProsemirrorTiptap & ProsemirrorbugSomething isn't workingSomething isn't workingtiptap
Activity