Skip to content

Put slash-menu blocks where the dialect allows them - #538

Merged
MaggieAppleton merged 5 commits into
mainfrom
design-slash-table-cell
Oct 13, 2026
Merged

MaggieAppleton merged 5 commits into
mainfrom
design-slash-table-cell

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Found while reviewing #499: the slash menu placed blocks with getTopLevelElement, which stops inside a table cell because Lexical treats cells as shadow roots. Every slash block typed in a cell landed inside it, but a cell holds only inline text in the dialect.

Reproduced in Chromium, typing / in a body cell. Callout, Columns, Diagram and Tabs were refused by the server ("Edits not saved"). Code, Formula and Diff were refused or saved as bare text. Table and Task list saved as corrupted cell text (MARK | | | and - [ ] MARK).

Before: /code in a cell After
Before After

Behaviour per action

Caret in… Block actions Inline-safe
Table cell After the whole table, as Research already does Image stays in the cell
Callout / column / tab Stays in the container, unless the dialect forbids it there: Callout in a callout or column, Tabs in tabs or a column. Those go after the container. —
List item Unchanged (already valid: Lexical splits the list) —

While checking columns I found the same root cause, containers that are not shadow roots, causing two more silent failures:

  • Table and Tabs inside a callout, column or tab were dropped. Lexical 0.48's $insertNodes flattens blocks to inline text there.
  • Task list in a callout replaced the whole callout. In a tab it produced an invalid list > Tab.

Both now insert relative to the caret's block.

Changes

  • New toolbar/block-target.ts holds the shared placement: $topLevel (moved from research.tsx), $openBlock, $leaveForBlock, $insertBlock and $taskListInContainer. Research, the slash menu, the phone dock's / button (OPEN_SLASH_MENU_COMMAND) and its Insert panel all use it, so the draft or / position and the insert position agree.
  • Each slash block runs through placed(), which moves the caret and runs the command in one update, so one undo removes both.
flowchart LR
  A[Slash block chosen] --> B{Caret in a table cell,<br/>or a container forbidding it?}
  B -- yes --> C[New paragraph after the top-level block]
  B -- no --> D[Caret stays]
  C --> E[Run command]
  D --> E
  E --> F{Host is a paragraph<br/>in a callout/column/tab?}
  F -- yes --> G[Insert relative to the host block]
  F -- no --> H[$insertNodes / $setBlocksType / $insertList]
Loading

Tests

  • block-target.test.ts (headless Lexical, 50 cases): each block action from a table cell lands after the table, the cell is unchanged, and the result is valid and round-trips. Image stays in the cell. $openBlock from a cell opens after the table. Every action in a list, columns, callout and tabs leaves a valid document, with forbidden and allowed placements asserted. Without the fix, 16 fail.
  • toolbar.e2e.ts: /code in an empty cell writes the fence after the table, with no "Edits not saved". Fails on main, passes here locally.
  • bun test (editor), bun run types and bun run ci pass.

Renews the design-contract source hash for slash.tsx in a separate commit. That covers only the hash: the listbox classes and position style are unchanged.

Review follow-ups

  • Columns now follows the same rule. The dialect marks Columns as topLevel, and the validator and the slash menu both read that flag. Columns can't be placed inside a callout or tab, because the dialect only allows them at the top level, so from any nested caret they go after the top-level block.
  • Phone dock uses the same placement. It opens a new line inside the caret's callout, column or tab, and goes after the whole table, list or quote only when the caret is in one. Opening the line and inserting the block happen in one update, so one undo removes both.
  • CONTAINER_COMPONENTS maps container node types to dialect names. A test checks it covers every dialect component that can hold a caret, and that each node type is registered.
  • Bubble Bulleted/Numbered list conversions and the dock's Task list: confirmed in headless tests and in Chromium that they replaced the whole callout, column or tab, and in a column or tab left an invalid document. $insertList walks up to a root or shadow root, so they now build the list in place and join an adjacent list of the same kind. An imported root quote converted to a list now gives one item per line, instead of an item holding a paragraph.
  • Tests: 66 headless cases in block-target.test.ts; 11 of the new ones fail on the previous code. Added an e2e for a bulleted list made inside a callout. In Chromium, Bulleted and Numbered list in a callout, column and tab, plus the 45-case slash matrix, all save with no rejected batches.
Bulleted list inside a callout
Bubble list

🤖 Generated with Claude Code

Landing validation

Preserves the author's insertion fixes and the corrected lane anchor. Duplicate connector test work already on main is excluded. Fresh CI, types, production build, 146 toolbar units, and all 22 toolbar browsers passed locally. Design and lane checks pass after rebasing onto current main. Prior GitHub CI was green; administrator bypass avoids another full cycle for this source-preserving refresh.

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good

Checked in Chromium against a fake-GitHub server built from this branch (desktop 1440×900 and phone 390×844).

What I verified

  • All 50 slash inserts (Callout, Table, Columns, Task list, Code, Image, Diagram, Formula, Tabs, Diff) from a table cell, list item, callout, column and tab. Each was saved by the server, with no "Edits not saved". Placement matches the PR: blocks typed in a cell go after the table and the cell stays as text. Image stays inline in the cell. Callout in a callout or column, and Tabs in a column or tab, go after the container. Table and Tabs in a callout, and Table in a column or tab, stay inside. Task list in a callout, column or tab now converts the paragraph without replacing the container.
  • Undo: a single Cmd+Z after /code in a cell, and after /tabs in a column, restores the saved source exactly. No stray empty paragraph is left behind.
  • Collaboration: client A inserted a Diagram from a cell and a Table inside a callout. Client B saw both live, and a reload of B matches the server snapshot.
  • Phone dock "Insert block": Code block from a cell lands after the table and saves.
  • Research: $topLevel moved over verbatim, and $insertResearch and $draftAttachment are unchanged.
  • bun test src/toolbar: 130 pass. Locally on ef15521c, bun run ci and bun run types are clean.

Dialect alignment (drift risk: low, with one gap)

  • $leaveForBlock reads the forbids lists from @chopin/dialect through lookup(), so the nesting rules are not copied. That's good.
  • The remaining hand-written parts:
    • componentName() maps 4 node types to component names (block-target.ts:63-69).
    • Each command passes its component name by hand ("Callout", "Tabs").
    • The cell rule is inline-only, but that comes from GFM, not the component table.
  • If a container node is added (or Questionnaire/Decision ever become editable hosts), componentName will silently miss it. Suggest deriving the name from the node's dialect registration, e.g. the importer/exporter name in container-visitors.ts, or a static componentName on each node. At minimum, add a test that every flow component with forbids and content: blocks has a mapping.

Non-blocking findings

  1. Columns ignores the rule the PR states. It isn't wrapped with a component (placed(..., ) has no "Columns"). It still uses getTopLevelElement and goes after a callout or tab, which both allow Columns. The document stays valid, but this is inconsistent with "stays inside unless the dialect forbids it". Fix: pass "Columns" and use $insertBlock(columns) like Table and Tabs.
  2. The phone dock disagrees with the slash menu in containers. insertChoices (dock-panel.tsx:73) runs $openBlock first, which always opens after the top-level block. Callout from inside a tab via the dock lands after the Tabs. The same action from the slash menu keeps it inside the tab. This predates the PR, but the PR claims the two paths now agree. It also runs as a separate discrete update before command.run, so dock inserts may take two undos. I didn't verify that.
  3. Caret after inserting a table. In the cell, callout, column and tab cases, the caret ends in the bottom-right cell (selectEnd), so typing fills the last body cell. The first body or header cell would be friendlier. Top-level insert has the same behaviour, so this isn't a regression.
  4. Phone, outside this PR's code: placing the caret in a table cell opens the table actions sheet over the format dock, which hides the "Insert block" button. Worth its own issue.

Follow-up already noted: the selection bubble's Bulleted/Numbered conversions still use $insertList, and probably replace the container. I didn't test them. Agree they should get the $taskListInContainer treatment in a separate PR.

CI: ef15521c (main merged in) is running now. "format, lint, types, tests" failed only on apps/connector/src/run.test.ts, the timing test "the grace period stops the agent on time while a heartbeat hangs in flight" (5037ms vs < 1500). That's unrelated to this PR, and main's connector tests are also red at de50c9de. The previous run on f4bea5af was all green.

MaggieAppleton and others added 5 commits October 13, 2026 12:21
A table cell is a Lexical shadow root, so `getTopLevelElement` stopped inside
it and every slash block (callout, table, columns, task list, code, diagram,
formula, tabs, diff) landed in the cell, which holds only inline text. The
server refused the batch or saved a corrupted cell. Those blocks now go after
the whole table, as Research already did; the inline Image stays in the cell.

The same placement helper also moves a callout or tabs out of a container
that forbids them, and inserts a table, tabs or task list inside a callout,
column or tab instead of `$insertNodes` dropping them or `$insertList`
replacing the container. The phone dock's `/` and Insert panel share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Source hash only: SlashMenu's SHELL, DIVIDER and ROW classes and its
position style are unchanged; the edits are in command run functions and
the phone slash handler, which carry no styling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups for the slash placement fix:

- Columns now go through the same rule. The dialect marks Columns
  `topLevel`, so the validator and the slash menu read one flag, and a
  Columns chosen anywhere nested lands after the top-level block.
- The phone dock opens its line beside the caret's block, inside its
  callout, column or tab, and only after the whole table, list or quote.
  It opens the line and inserts the block in one update, so one undo
  removes both.
- `CONTAINER_COMPONENTS` maps container node types to dialect names; a
  test checks it covers every component that can hold a caret.
- Bulleted, Numbered and Task list conversions from the selection bubble
  and the dock kept replacing a whole callout, column or tab, because
  `$insertList` walks up to a root or shadow root. They now build the list
  in place, joining an adjacent list of the same kind. An imported quote
  becomes one item per line instead of an item holding a paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Source hashes only: in bubble.tsx and slash.tsx the edits are in
convertBlock and command run functions; the toolbar and menu classes and
position styles are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton force-pushed the design-slash-table-cell branch from 2905109 to 9557f87 Compare October 13, 2026 11:22
@MaggieAppleton
MaggieAppleton merged commit 6fc1c49 into main Oct 13, 2026
3 of 4 checks passed
@MaggieAppleton
MaggieAppleton deleted the design-slash-table-cell branch October 13, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant