Skip to content

Undo a Slash pick with one Mod-z, and hide rows that lift their own block #332

Description

@HMarzban

Parent

#328. Related: #251

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 /h2 and 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 /h2 paragraph, and the Slash menu opens on it again.
  • In an empty item, the row whose type equals the nearest list is absent. Example: /bul in 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), then item.run(editor), and sets no undo boundary (apps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.ts:63-70).
  • The Yjs UndoManager merges changes less than 500 ms apart (captureTimeout = 500, yjs 13.6.32 dist/yjs.mjs:3580). The pad's yUndoPlugin sets no other value (@tiptap/y-tiptap dist/y-tiptap.js:2866-2869).
  • So a fast /h2 Enter 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).
  • toggleList with the same list type calls liftListItem (@tiptap/core src/commands/toggleList.ts). toggleWrap calls lift when a blockquote is already active (@tiptap/core src/commands/toggleWrap.ts:25-28).

Desired behavior:

  • Stop Yjs capture before deleteRange and again after item.run. The first call keeps the pick apart from the typing. The second keeps the next keys apart from the pick.
  • The heading toc-id stamp runs in appendTransaction (@tiptap/extension-unique-id 3.31.3 dist/index.js:118). That lands in the same dispatch, so the second call does not split it from the heading.
  • After a pick, @tiptap/suggestion 3.31.3 sets no dismissed range. It sets one only on exit. So the restored /h2 opens 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 calls yUndoPluginKey.getState(editor.state)?.undoManager.stopCapturing(). The same handle is used at toolbar/desktop/applyImportedContent.ts:20. The webapp and @tiptap/extension-collaboration resolve the same @tiptap/y-tiptap copy (3.0.9), so this key finds the pad's undo plugin. Optional chaining covers the UndoRedo path, 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.ts command, call the helper before deleteRange and after item.run.
  • Add apps/webapp/src/components/TipTap/slash/slashContext.ts. It is the one home for caret checks that row can functions use. Put nearestListType(editor) there. Build it with findParentNode from @tiptap/core over bulletList, orderedList and taskList.
  • Do not use isActive for lists. Lists nest, and isActive is true for any list ancestor.
  • In slashItems.ts, replace can: always on 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 on main since commit e7271cc54. Prove each by sabotage.
  • AGENTS.md §Test Orchestration And Authoring: use realType. cy.realPress is 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 beforeEach visits /editor with persist: true. That loads Collaboration on a local Y.Doc, so the pad has a Yjs undo manager. Without it the playground uses plain UndoRedo (apps/webapp/src/components/TipTap/TipTap.tsx:249). Keep persist: true.
    • After startIn, stop capture once, because its setContent would merge with fast typing.
    • Reach the undo manager through window._editor.state.plugins. Find the plugin whose key starts with y-undo$, then call getState(state).undoManager. new PluginKey('y-undo') (@tiptap/y-tiptap dist/y-tiptap.js:35) gives the key y-undo$, or y-undo$1 if a second copy loads. Fail the test when the lookup finds nothing. Add no window globals.
    • realType('/h2'). Read undoStack.length as L. Press Enter, then realType('x') at once. Assert the length is L + 2.
    • Pick the modifier like cypress/e2e/editor/find/caret-find.cy.ts:18: Meta on darwin, Control elsewhere. keyboard-shortcuts.cy.js presses 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.
  • 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 assert orderedList and taskList are present.
    • Start in <h1>Doc</h1><blockquote><p></p></blockquote> and type /. Assert no [data-slash-item="blockquote"].
  • From the repo root, run bun run check.
  • Start the local stack with make dev-local from the repo root. On first run it writes .env.local, which bun run dev in apps/webapp needs. Note the webapp port; it is not always 3000 (AGENTS.md §Test Policy; README.md local development).
  • From apps/webapp, run bunx 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: always on Bullet List.
    • Restore can: always on 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.
  • 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

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

    EditorTiptap & ProsemirrorbugSomething isn't workingtiptap

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions